fix(sync): restore automatic sleep bluetooth synchronization

This commit is contained in:
fy59 2026-09-24 06:00:29 +02:00
parent 2755d29f2d
commit 3a51211f01
15 changed files with 548 additions and 29 deletions

View file

@ -20,6 +20,21 @@ Dashboard sizing, Program progress contrast, chart proportions, duration
coverage messaging, and the right-aligned Settings/Sync action cluster are coverage messaging, and the right-aligned Settings/Sync action cluster are
also refined. also refined.
- Restored automatic Sleep-only Bluetooth synchronization after a real-night
trace exposed several restart and first-import gaps. The user-service
installer now removes only its obsolete `syncd` ExecStart override and
installs the RFCOMM agent;
Android resumes the same unsuperseded immutable generation across a new
conversation run and accepts its exact generation/digest ACK even though
the immutable ACK retains the original run identity. Ledger-proven
superseded branches remain retained without exhausting admission. Desktop
progress validation accepts the existing ACK-recovery phase, and a fresh
desktop can seed a non-root Sleep V2 current snapshot when its validated
ancestry is present. Regression
coverage carries medication quantity, Couché/Réveil/Levé and Sleep BPM/RR
through import, ACK, replay and a second Sleep-only generation with no
Android workout session.
## 0.1.5 — 2026-09-23 ## 0.1.5 — 2026-09-23
- Completed the Sleep Tonight readiness delta and its non-destructive private - Completed the Sleep Tonight readiness delta and its non-destructive private

View file

@ -101,6 +101,19 @@ internal fun hasBluetoothConnectPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED PackageManager.PERMISSION_GRANTED
/**
* Decide whether a newly connected RFCOMM peer may publish an automatic request.
*
* WHY: Sleep-only changes have no workout event to wake the desktop path, so
* Bluetooth arrival itself must remain the trigger. CONTRACT: user opt-in and
* the existing cooldown are both mandatory. INVARIANT: reconnection never
* bypasses the bounded request cadence or requires an explicit UI action.
*/
internal fun shouldPublishBluetoothArrivalRequest(
autoSyncEnabled: Boolean,
cooldownReady: Boolean,
): Boolean = autoSyncEnabled && cooldownReady
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
internal fun bondedBluetoothDesktops(context: Context): List<BondedBluetoothDesktop> { internal fun bondedBluetoothDesktops(context: Context): List<BondedBluetoothDesktop> {
if (!hasBluetoothConnectPermission(context)) return emptyList() if (!hasBluetoothConnectPermission(context)) return emptyList()
@ -526,9 +539,15 @@ internal class BluetoothSyncClient(
} }
private fun publishArrivalRequest() { private fun publishArrivalRequest() {
if (!BackgroundSyncSettings(context).enabled) return
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (!settings.shouldPublishAutomaticRequest(now)) return if (
!shouldPublishBluetoothArrivalRequest(
BackgroundSyncSettings(context).enabled,
settings.shouldPublishAutomaticRequest(now),
)
) {
return
}
val directory = canonicalExchangeDirectory() val directory = canonicalExchangeDirectory()
if (!directory.isDirectory) return if (!directory.isDirectory) return
val coordinator = val coordinator =

View file

@ -45,6 +45,13 @@ internal object AndroidSyncGenerationTrace : SyncGenerationTrace {
} }
} }
internal fun acknowledgementMatchesCapturedGeneration(
acknowledgement: JSONObject,
captured: CapturedSyncGeneration,
): Boolean =
acknowledgement.optString("generation_id") == captured.generationId &&
acknowledgement.optString("manifest_sha256") == captured.manifestSha256
/** Owns the bounded, user-initiated Android side of a generation conversation. */ /** Owns the bounded, user-initiated Android side of a generation conversation. */
internal class SyncGenerationCoordinator( internal class SyncGenerationCoordinator(
private val repository: TrainlogRepository, private val repository: TrainlogRepository,
@ -69,15 +76,9 @@ internal class SyncGenerationCoordinator(
internal fun resumableGeneration(runId: String, consumerPeerId: String? = null): CapturedSyncGeneration? = internal fun resumableGeneration(runId: String, consumerPeerId: String? = null): CapturedSyncGeneration? =
repository.inSyncGenerationTransaction { db -> repository.inSyncGenerationTransaction { db ->
db.rawQuery( fun query(sql: String, arguments: Array<String>): CapturedSyncGeneration? = db.rawQuery(
"SELECT generation_id,manifest_sha256,staging_path FROM sync_generations " + sql,
"WHERE run_id=? AND (status IN('captured','published','waiting_acknowledgement') " + arguments,
"OR (status='acknowledged' AND NOT EXISTS(" +
"SELECT 1 FROM sync_consumed_generations c WHERE c.run_id=sync_generations.run_id " +
"AND c.result IN('consumed','rejected')))) " +
(if (consumerPeerId == null) "" else "AND consumer_peer_id=? ") +
"LIMIT 2",
if (consumerPeerId == null) arrayOf(runId) else arrayOf(runId, consumerPeerId),
).use { cursor -> ).use { cursor ->
if (!cursor.moveToFirst()) null if (!cursor.moveToFirst()) null
else { else {
@ -93,6 +94,29 @@ internal class SyncGenerationCoordinator(
captured captured
} }
} }
query(
"SELECT generation_id,manifest_sha256,staging_path FROM sync_generations " +
"WHERE run_id=? AND (status IN('captured','published','waiting_acknowledgement') " +
"OR (status='acknowledged' AND NOT EXISTS(" +
"SELECT 1 FROM sync_consumed_generations c WHERE c.run_id=sync_generations.run_id " +
"AND c.result IN('consumed','rejected')))) " +
(if (consumerPeerId == null) "" else "AND consumer_peer_id=? ") +
"LIMIT 2",
if (consumerPeerId == null) arrayOf(runId) else arrayOf(runId, consumerPeerId),
) ?: if (consumerPeerId == null) null else query(
/* WHY: an RFCOMM reset creates a new conversation run while
* the immutable generation still awaits its correlated ACK.
* CONTRACT: resume only the unsuperseded generation for the
* same peer; the reference adopts the new run while manifest
* and generation identity remain unchanged. */
"SELECT g.generation_id,g.manifest_sha256,g.staging_path FROM sync_generations g " +
"WHERE g.consumer_peer_id=? AND g.status IN('captured','published','waiting_acknowledgement') " +
"AND NOT EXISTS(SELECT 1 FROM sync_generations sibling JOIN sync_acknowledgements a " +
"ON a.generation_id=sibling.generation_id WHERE sibling.parent_generation_id=g.parent_generation_id " +
"AND sibling.generation_id<>g.generation_id AND sibling.status='acknowledged') " +
"ORDER BY g.generated_at DESC LIMIT 2",
arrayOf(consumerPeerId),
)
} }
private fun request(directory: File): JSONObject? { private fun request(directory: File): JSONObject? {
@ -570,12 +594,15 @@ internal class SyncGenerationCoordinator(
afterPublication?.invoke() afterPublication?.invoke()
lease.throwIfYieldRequested() lease.throwIfYieldRequested()
// CONTRACT: durable objects from earlier conversations remain in // CONTRACT: durable objects from earlier conversations remain in
// the exchange directory. Only this run's exact generation ACK // the exchange directory. A cross-run retry preserves the
// can advance its lineage; stale bytes are retained and ignored. // immutable manifest's original run_id, so correlate its ACK by
// generation identity and manifest digest. acceptAcknowledgement
// then revalidates the complete durable ACK against SQLite.
// INVARIANT: unrelated or same-ID/different-bytes ACKs remain
// ignored; an exact replay is deliberately accepted.
val desktopAck = val desktopAck =
awaitCorrelated(File(directory, "desktop-consumption-ack-v1.json"), deadline, { awaitCorrelated(File(directory, "desktop-consumption-ack-v1.json"), deadline, {
it.optString("run_id") == captured.runId && acknowledgementMatchesCapturedGeneration(it, captured)
it.optString("generation_id") == captured.generationId
}, pollTransport, lease) }, pollTransport, lease)
service.acceptAcknowledgement(desktopAck) service.acceptAcknowledgement(desktopAck)
phase(runId, "desktop_ack_observed", "desktop-consumption-ack-v1.json", captured.generationId) phase(runId, "desktop_ack_observed", "desktop-consumption-ack-v1.json", captured.generationId)

View file

@ -299,12 +299,18 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
* ledger row. Such terminal rows cannot be archived * ledger row. Such terminal rows cannot be archived
* safely because Trainlog must not fabricate evidence, * safely because Trainlog must not fabricate evidence,
* but they must not exhaust the active admission window * but they must not exhaust the active admission window
* forever. Current acknowledgements always have durable * forever. A failed conversation can also leave an
* ledger evidence and remain counted until archived. * unacknowledged branch whose sibling later receives a
* INVARIANT: captured, published and unacknowledged rows * durable ACK; that superseded branch remains retained
* are always retained and never pruned for capacity. */ * for audit but can never be the causal tip. INVARIANT:
* only a ledger-backed acknowledged sibling proves that
* an unacknowledged branch no longer consumes admission. */
"SELECT COUNT(*) FROM sync_generations g WHERE consumer_peer_id=? AND " + "SELECT COUNT(*) FROM sync_generations g WHERE consumer_peer_id=? AND " +
"(status IN('captured','published','waiting_acknowledgement') OR " + "((status IN('captured','published','waiting_acknowledgement') AND NOT EXISTS(" +
"SELECT 1 FROM sync_generations sibling JOIN sync_acknowledgements sibling_ack " +
"ON sibling_ack.generation_id=sibling.generation_id WHERE " +
"sibling.parent_generation_id=g.parent_generation_id AND " +
"sibling.generation_id<>g.generation_id AND sibling.status='acknowledged')) OR " +
"(status='acknowledged' AND EXISTS(SELECT 1 FROM sync_acknowledgements k " + "(status='acknowledged' AND EXISTS(SELECT 1 FROM sync_acknowledgements k " +
"WHERE k.generation_id=g.generation_id))) AND NOT EXISTS(" + "WHERE k.generation_id=g.generation_id))) AND NOT EXISTS(" +
"SELECT 1 FROM sync_generation_archives a WHERE a.generation_id=g.generation_id)", "SELECT 1 FROM sync_generation_archives a WHERE a.generation_id=g.generation_id)",

View file

@ -1,9 +1,32 @@
package com.labfytools.trainlog.data package com.labfytools.trainlog.data
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class BluetoothSyncTransportContractTest { class BluetoothSyncTransportContractTest {
@Test
fun connectedPeerStartsAutoSyncWithoutUserActionButHonorsCooldown() {
assertTrue(
shouldPublishBluetoothArrivalRequest(
autoSyncEnabled = true,
cooldownReady = true,
)
)
assertFalse(
shouldPublishBluetoothArrivalRequest(
autoSyncEnabled = false,
cooldownReady = true,
)
)
assertFalse(
shouldPublishBluetoothArrivalRequest(
autoSyncEnabled = true,
cooldownReady = false,
)
)
}
@Test @Test
fun androidPullArchiveCarriesGenerationAckRecoveryEvidence() { fun androidPullArchiveCarriesGenerationAckRecoveryEvidence() {
assertTrue( assertTrue(

View file

@ -11,6 +11,7 @@ import com.labfytools.trainlog.model.SessionDraft
import com.labfytools.trainlog.model.SessionExerciseDraft import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.SleepDiaryDraft import com.labfytools.trainlog.model.SleepDiaryDraft
import com.labfytools.trainlog.model.SleepMedication
import com.labfytools.trainlog.model.TrackingMode import com.labfytools.trainlog.model.TrackingMode
import java.io.File import java.io.File
import java.nio.file.Files import java.nio.file.Files
@ -30,6 +31,238 @@ import org.robolectric.annotation.Config
class SyncGenerationServiceTest { class SyncGenerationServiceTest {
private val context: Context = ApplicationProvider.getApplicationContext() private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun sleepOnlyNightCrossesDesktopAckReplayAndSecondRevisionWithoutSession() {
val sourceName = "sleep-only-generation-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-sleep-only-generation-").toFile()
val desktopDb = java.io.File(root, "desktop.sqlite")
val repositoryRoot =
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
it.parentFile
}
.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
val generationTool =
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath
var source = TrainlogRepository(context, sourceName)
fun run(vararg command: String): String {
val process =
ProcessBuilder(*command)
.directory(repositoryRoot)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
assertEquals(
"command failed: ${command.joinToString(" ")}\n$output",
0,
process.waitFor(),
)
return output
}
try {
run(
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktopDb.absolutePath,
)
val desktopPeer =
Regex("PEER_ID=(peer_[^\\s]+)")
.find(
run(
"python3",
generationTool,
"peer-id",
"--database",
desktopDb.absolutePath,
)
)!!
.groupValues[1]
val medicationCreated =
source.saveSleepMedication(
SleepMedication(
medicationId = "",
revisionId = "",
createdAt = "2026-09-23T20:00:00+02:00",
updatedAt = "2026-09-23T20:00:00+02:00",
name = "Synthetic sleep-only medication",
defaultDoseValue = 5.0,
defaultDoseUnit = "mg",
form = "tablet",
note = "",
active = true,
)
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val preBed =
source.quickSleepMedication(
medicationCreated.entryId,
"2026-09-23T22:20:00+02:00",
quantity = 2,
) as TrainlogRepository.SleepQuickActionResult.Applied
val bed =
source.quickSleepBedTime("2026-09-23T22:30:00+02:00")
as TrainlogRepository.SleepQuickActionResult.Applied
assertEquals(preBed.receipt.entryId, bed.receipt.entryId)
val firstMeasurement =
ParsedHeartRateMeasurement(
bpm = 61,
sensorContactDetected = true,
energyExpended = 7,
rrIntervals1024 = listOf(1000),
)
assertTrue(
source.recordLiveHeartRateForActiveSleep(
"Synthetic HR",
"2026-09-23T22:30:01+02:00",
firstMeasurement,
) is TrainlogRepository.HeartRateMutationResult.Applied
)
assertTrue(
source.quickSleepWake("2026-09-24T02:17:00+02:00")
is TrainlogRepository.SleepQuickActionResult.Applied
)
assertTrue(
source.recordLiveHeartRateForActiveSleep(
"Synthetic HR",
"2026-09-24T02:17:01+02:00",
firstMeasurement.copy(bpm = 65, rrIntervals1024 = listOf(980)),
) is TrainlogRepository.HeartRateMutationResult.Applied
)
assertTrue(
source.quickSleepFinalGetUp("2026-09-24T06:31:00+02:00")
is TrainlogRepository.SleepQuickActionResult.Applied
)
assertTrue(source.listSessions().isEmpty())
val night = source.listSleepDiary().single()
val capture =
JSONObject(source.buildHeartRateV1Json())
.getJSONArray("captures")
.getJSONObject(0)
assertEquals(night.entryId, capture.getString("context_id"))
assertEquals(2, capture.getJSONArray("samples").length())
assertEquals(2, night.intakes.single().quantity)
val service = SyncGenerationService(source)
val captured = service.capture(root, desktopPeer)
val diaryArtifact =
JSONObject(java.io.File(captured.stagingDirectory, "sleep-diary-v2.json").readText())
val heartArtifact =
JSONObject(java.io.File(captured.stagingDirectory, "heart-rate-v1.json").readText())
val historyArtifact =
JSONObject(java.io.File(captured.stagingDirectory, "history-v4.json").readText())
assertEquals(0, historyArtifact.getJSONArray("sessions").length())
val publishedNight = diaryArtifact.getJSONArray("entries").getJSONObject(0)
val publishedCapture = heartArtifact.getJSONArray("captures").getJSONObject(0)
assertEquals(night.entryId, publishedNight.getString("entry_id"))
assertEquals(
2,
publishedNight.getJSONArray("intakes").getJSONObject(0).getInt("quantity"),
)
assertEquals(capture.getString("capture_id"), publishedCapture.getString("capture_id"))
val published = service.publish(captured, java.io.File(root, "android-objects"))
val ack = java.io.File(root, "sleep-only-ack.json")
run(
"python3",
generationTool,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
ack.absolutePath,
)
val ackDocument = JSONObject(ack.readText())
assertEquals(
"desktop ACK diagnostic: ${ackDocument.getString("diagnostic")}",
"consumed",
ackDocument.getString("result"),
)
assertEquals("acknowledged", service.acceptAcknowledgement(ack.readBytes()))
assertEquals("unchanged", service.acceptAcknowledgement(ack.readBytes()))
run(
"python3",
generationTool,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
java.io.File(root, "sleep-only-replay-ack.json").absolutePath,
)
assertEquals(
// The desktop fixture owns one reserved calibration session;
// the empty Android history must not add a workout.
"1|1|2|2|2|1",
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT (SELECT count(*) FROM sleep_diary_entries WHERE entry_id='${night.entryId}') || '|' || " +
"(SELECT count(*) FROM sleep_medication_intakes WHERE revision_id='${night.revisionId}') || '|' || " +
"(SELECT quantity FROM sleep_medication_intakes WHERE revision_id='${night.revisionId}') || '|' || " +
"(SELECT count(*) FROM heart_rate_samples WHERE capture_id='${capture.getString("capture_id")}') || '|' || " +
"(SELECT count(*) FROM heart_rate_rr_intervals WHERE capture_id='${capture.getString("capture_id")}') || '|' || " +
"(SELECT count(*) FROM sessions);",
)
.trim(),
)
val revised =
source.saveSleepDiary(
SleepDiaryDraft(
entryId = night.entryId,
expectedRevision = night.revisionId,
nightStartDate = night.nightStartDate,
nightEndDate = night.nightEndDate,
createdAt = night.createdAt,
updatedAt = "2026-09-24T07:00:00+02:00",
sleepQuality = night.sleepQuality,
wakeQuality = night.wakeQuality,
dayForm = night.dayForm,
treatmentAndNotes = "Sleep-only revision two",
events = night.events,
intakes = night.intakes,
)
) as TrainlogRepository.SaveSleepDiaryResult.Saved
assertTrue(
source.validateSleepDiary(
revised.entryId,
revised.revisionId,
"2026-09-24T07:01:00+02:00",
) is TrainlogRepository.SaveSleepDiaryResult.Saved
)
val second = service.capture(root, desktopPeer)
val secondPublished =
service.publish(second, java.io.File(root, "android-objects-second"))
val secondAck = java.io.File(root, "sleep-only-second-ack.json")
run(
"python3",
generationTool,
"consume-desktop",
secondPublished.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
secondAck.absolutePath,
)
assertEquals("acknowledged", service.acceptAcknowledgement(secondAck.readBytes()))
assertEquals(
revised.revisionId,
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT current_revision_id FROM sleep_diary_entries WHERE entry_id='${night.entryId}';",
)
.trim(),
)
assertTrue(source.listSessions().isEmpty())
} finally {
source.close()
context.deleteDatabase(sourceName)
root.deleteRecursively()
}
}
@Test @Test
fun desktopProgramDeletionGenerationIsAckedAndCannotResurrect() { fun desktopProgramDeletionGenerationIsAckedAndCannotResurrect() {
val repositoryRoot = val repositoryRoot =
@ -322,6 +555,13 @@ class SyncGenerationServiceTest {
destinationService.peerId(), destinationService.peerId(),
runId, runId,
) )
val retryRun = "sy_${UUID.randomUUID()}"
val crossRunResume =
SyncGenerationCoordinator(source)
.resumableGeneration(retryRun, destinationService.peerId())
assertEquals(outbound.generationId, crossRunResume?.generationId)
assertEquals(retryRun, crossRunResume?.runId)
assertEquals(outbound.manifestSha256, crossRunResume?.manifestSha256)
val outboundDirectory = val outboundDirectory =
sourceService.publish(outbound, java.io.File(root, "source-objects")) sourceService.publish(outbound, java.io.File(root, "source-objects"))
sourceService.acceptAcknowledgement( sourceService.acceptAcknowledgement(
@ -353,6 +593,74 @@ class SyncGenerationServiceTest {
} }
} }
@Test
fun crossRunResumeAcceptsExactImmutableAcknowledgementFromOriginalRun() {
val generationId = "gen_${UUID.randomUUID()}"
val originalRunId = "sy_${UUID.randomUUID()}"
val retryRunId = "sy_${UUID.randomUUID()}"
val digest = "a".repeat(64)
val captured =
CapturedSyncGeneration(generationId, retryRunId, digest, java.io.File("unused"))
val acknowledgement =
JSONObject()
.put("run_id", originalRunId)
.put("generation_id", generationId)
.put("manifest_sha256", digest)
assertTrue(acknowledgementMatchesCapturedGeneration(acknowledgement, captured))
acknowledgement.put("manifest_sha256", "b".repeat(64))
assertTrue(!acknowledgementMatchesCapturedGeneration(acknowledgement, captured))
}
@Test
fun ledgerBackedSiblingMakesRetainedWaitingBranchesCapacityTerminal() {
val name = "generation-superseded-capacity-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-generation-superseded-").toFile()
val repository = TrainlogRepository(context, name)
try {
val service = SyncGenerationService(repository)
val peer = "peer_11111111-1111-4111-8111-111111111111"
val branches = (0 until 4).map { service.capture(root, peer) }
val acknowledged = branches.last()
repository.inSyncGenerationTransaction { db ->
db.execSQL(
"UPDATE sync_generations SET parent_generation_id=? WHERE consumer_peer_id=?",
arrayOf("gen_22222222-2222-4222-8222-222222222222", peer),
)
db.execSQL(
"UPDATE sync_generations SET status='acknowledged',acknowledged_at=? " +
"WHERE generation_id=?",
arrayOf("2026-09-24T07:00:00+02:00", acknowledged.generationId),
)
db.execSQL(
"INSERT INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)",
arrayOf(
"ack_${UUID.randomUUID()}",
acknowledged.generationId,
acknowledged.runId,
service.peerId(),
peer,
acknowledged.manifestSha256,
"consumed",
"sqlite-commit-full",
"2026-09-24T07:00:00+02:00",
"",
"a".repeat(64),
),
)
}
// The three sibling branches remain durable, but the ledger-backed
// acknowledged sibling proves that none can be the causal tip.
assertEquals(3, branches.dropLast(1).size)
assertTrue(service.capture(root, peer).generationId.startsWith("gen_"))
} finally {
repository.close()
context.deleteDatabase(name)
root.deleteRecursively()
}
}
@Test @Test
fun coordinatorOnlyTreatsCorrelatedFreshRequestAsActionable() { fun coordinatorOnlyTreatsCorrelatedFreshRequestAsActionable() {
val databaseName = "generation-actionable-${UUID.randomUUID()}.db" val databaseName = "generation-actionable-${UUID.randomUUID()}.db"

View file

@ -10,6 +10,16 @@ the wired recovery path. The separately configured private Drive transport
remains available to its legacy mirror/fallback workflow. No transport copies remains available to its legacy mirror/fallback workflow. No transport copies
SQLite. SQLite.
Automatic Bluetooth restart recovery preserves the immutable generation
identity across conversation-run changes and recognizes the exact original-run
ACK by immutable generation ID and manifest digest before full SQLite
validation. An unacknowledged branch is excluded
from the bounded admission count only when a different sibling has a durable,
ledger-backed consumed acknowledgement; the superseded bytes and row remain
retained. The desktop service installer retires the exact obsolete private
ExecStart override that omitted required runtime paths and installs both
`trainlog-syncd` and the BlueZ RFCOMM agent.
Android synchronization paths share the same concurrency-guarded generation Android synchronization paths share the same concurrency-guarded generation
coordinator as SyncScreen. That coordinator has one process-wide ownership coordinator as SyncScreen. That coordinator has one process-wide ownership
arbiter: explicit foreground intent has priority over automatic background arbiter: explicit foreground intent has priority over automatic background

View file

@ -953,6 +953,20 @@ active. Archive directories are outside bounded MTP polling and are never
uploaded recursively. Failure or interruption leaves admission closed rather uploaded recursively. Failure or interruption leaves admission closed rather
than fabricating an ACK or discarding evidence. than fabricating an ACK or discarding evidence.
An unacknowledged generation whose parent also has a distinct, ledger-backed
acknowledged child is a retained superseded branch: it stays available for
audit and is never deleted or assigned a fabricated ACK, but it no longer
consumes active admission because it cannot become the causal tip. After a
transport reset, the next run republishes the newest unsuperseded immutable
generation for that peer before attempting a new capture. The mutable
generation reference uses the new conversation run; generation ID, manifest,
artifact bytes and eventual ACK correlation remain unchanged. Because the
immutable manifest and its durable ACK retain the original run identity, a
cross-run retry recognizes that ACK by the exact generation ID and manifest
digest; the producer's SQLite acknowledgement validator still verifies the
complete peer, result, durability and original-run contract before advancing
state.
Before Android captures for a negotiated `generation-archive-v1` conversation, Before Android captures for a negotiated `generation-archive-v1` conversation,
the desktop consumer publishes the desktop consumer publishes
`desktop-archive-acknowledgements-v1.json`. Its strictly correlated envelope `desktop-archive-acknowledgements-v1.json`. Its strictly correlated envelope
@ -999,11 +1013,14 @@ idempotently. Causal V1 operation bytes remain unchanged across first emission
and retransmission because generation membership lives only in and retransmission because generation membership lives only in
`sync_causal_publications`. `sync_causal_publications`.
The foreground Android coordinator correlates both durable desktop ACKs and The foreground Android coordinator correlates desktop generation references
desktop generation references by the active run (and the Android generation by the active run. It correlates a durable desktop ACK for an ordinary attempt
for its ACK). Objects retained from an interrupted conversation are ignored by the generation and manifest of that run; for a cross-run resume, those same
until atomically replaced by matching objects. After both peer ACKs, Android two immutable identities deliberately recognize the exact ACK whose embedded
reports completion directly; it does not enter the legacy receipt wait state. run remains the original capture run. Objects retained from an interrupted
conversation are ignored until atomically replaced by matching objects. After
both peer ACKs, Android reports completion directly; it does not enter the
legacy receipt wait state.
The peer publication precedes one fresh The peer publication precedes one fresh
`trainlog-sync-full-generation-request-v1` signal on `trainlog-sync-full-generation-request-v1` signal on

View file

@ -25,6 +25,18 @@ still conflicts. The equipment client test removes `crypto.randomUUID`, uses
deterministic `getRandomValues` bytes, and completes the protected merge HTTP deterministic `getRandomValues` bytes, and completes the protected merge HTTP
request; native merge tests retain their transaction/rollback coverage. request; native merge tests retain their transaction/rollback coverage.
The real Sleep Bluetooth recovery regression starts with no Android session,
records a pre-bed medication with `quantity=2`, Couché, two BPM/RR samples
separated by Réveil, and Levé. It captures and publishes the production V2
Sleep and V1 heart-rate artifacts, imports them with the desktop consumer,
accepts and replays the ACK without duplication, then admits a second
Sleep-only revision. Separate tests prove connection-arrival auto-request
policy with its cooldown, cross-run reuse of an unacknowledged immutable
generation and recognition of its original-run exact generation/digest ACK,
ledger-backed superseded-branch capacity, fresh-peer non-root Sleep ancestry,
service-installer upgrade behavior, and ACK-recovery progress frames over the
Bluetooth-selected orchestrator path.
UI regressions assert the Sleep labels at exactly 0/25/50/75/100%, persisted UI regressions assert the Sleep labels at exactly 0/25/50/75/100%, persisted
Dashboard sizing and reset behavior, English preference persistence across a Dashboard sizing and reset behavior, English preference persistence across a
remount, centralized Dashboard English labels, non-stretched chart aspect remount, centralized Dashboard English labels, non-stretched chart aspect

View file

@ -186,6 +186,47 @@ class SleepExchangeTest(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "concurrent"): with self.assertRaisesRegex(ValueError, "concurrent"):
exchange.apply(self.db, sibling) exchange.apply(self.db, sibling)
def test_current_snapshot_seeds_fresh_peer_from_non_root_revisions(self):
parent = "slr_20000000-0000-4000-8000-000000000002"
tip = "slr_40000000-0000-4000-8000-000000000004"
document = self.document(entry(tip, parent))
document["version"] = 2
document["entries"][0]["intakes"][0]["quantity"] = 2
document["entries"][0]["ancestry"] = [tip, parent]
medication = document["medications"][0]
medication_parent = medication["revision_id"]
medication_tip = "medr_a0000000-0000-4000-8000-00000000000a"
medication["revision_id"] = medication_tip
medication["parent_revision_id"] = medication_parent
medication["ancestry"] = [medication_tip, medication_parent]
medication["updated_at"] = "2026-10-25T18:05:00+01:00"
rejected = self.document(entry(
"slr_50000000-0000-4000-8000-000000000005",
"slr_60000000-0000-4000-8000-000000000006",
))
rejected["version"] = 2
rejected["entries"][0]["intakes"][0]["quantity"] = 2
with self.assertRaisesRegex(ValueError, "unknown parent"):
exchange.apply(self.db, rejected)
self.db.rollback()
self.assertEqual((1, 0), exchange.apply(self.db, document))
self.assertEqual(
(tip, parent),
self.db.execute(
"SELECT revision_id,parent_revision_id FROM sleep_diary_revisions"
).fetchone(),
)
self.assertEqual(
(medication_tip, medication_parent),
self.db.execute(
"SELECT revision_id,parent_revision_id FROM sleep_medication_revisions"
).fetchone(),
)
def test_rejects_concurrent_sibling_and_negative_absolute_interval(self): def test_rejects_concurrent_sibling_and_negative_absolute_interval(self):
first = "slr_20000000-0000-4000-8000-000000000002" first = "slr_20000000-0000-4000-8000-000000000002"
self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(first)))) self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(first))))

View file

@ -13,6 +13,17 @@ import sync_generation_exchange as generation
class DeploymentToolsTest(unittest.TestCase): class DeploymentToolsTest(unittest.TestCase):
def test_user_service_installer_retires_only_known_legacy_exec_override(self):
installer = (ROOT / "tools/install_syncd_user.sh").read_text()
self.assertIn(
"grep -Fxq 'ExecStart=%h/.local/bin/trainlog-syncd --interval 3'",
installer,
)
self.assertIn('rm -f -- "$LEGACY_ROLLOUT"', installer)
self.assertIn('--sync-once "$SYNC_ONCE"', installer)
self.assertIn('--bt-adapter "$BT_ADAPTER"', installer)
self.assertIn('--mtp-adapter "$MTP_ADAPTER"', installer)
def test_sqlite_backup_captures_committed_wal_and_restores_invariants(self): def test_sqlite_backup_captures_committed_wal_and_restores_invariants(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)

View file

@ -156,7 +156,10 @@ class OrchestratorTest(unittest.TestCase):
"assert sys.argv[sys.argv.index('--mode')+1]=='bt'\n" "assert sys.argv[sys.argv.index('--mode')+1]=='bt'\n"
"assert '--bt-adapter' in sys.argv\n" "assert '--bt-adapter' in sys.argv\n"
"v=" + repr(report) + "v=" + repr(report) +
"\nv['run_id']=sys.argv[sys.argv.index('--run-id')+1]\nprint(json.dumps(v))\n" "\nv['run_id']=sys.argv[sys.argv.index('--run-id')+1]\n"
"p={'format':'trainlog-sync-progress','version':1,'run_id':v['run_id'],"
"'phase':'android_archive_ack_observed','recovered_acknowledgements':1}\n"
"print(json.dumps(p))\nprint(json.dumps(v))\n"
) )
bt_marker = root / "bt-probe" bt_marker = root / "bt-probe"
bt_adapter = sandbox / "trainlog_generation_bt_adapter.py" bt_adapter = sandbox / "trainlog_generation_bt_adapter.py"
@ -175,7 +178,7 @@ class OrchestratorTest(unittest.TestCase):
bluetooth_device_address="AA:BB:CC:DD:EE:FF", bluetooth_device_address="AA:BB:CC:DD:EE:FF",
) )
result = self.invoke(sandbox / "sync_orchestrator.py", values) result = self.invoke(sandbox / "sync_orchestrator.py", values)
self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.returncode, 0, result.stderr or values[1].read_text())
self.assertTrue(bt_marker.is_file()) self.assertTrue(bt_marker.is_file())
self.assertFalse(mtp_marker.exists()) self.assertFalse(mtp_marker.exists())
final = json.loads(values[1].read_text()) final = json.loads(values[1].read_text())

View file

@ -24,6 +24,18 @@ done
mkdir -p "$HOME/.local/bin" "$HOME/.config/systemd/user" mkdir -p "$HOME/.local/bin" "$HOME/.config/systemd/user"
# WHY: private pre-orchestrator rollouts installed this exact drop-in to call
# syncd without its now-required bounded runtime paths. Leaving it in place
# silently overrides the canonical unit below and creates an endless restart
# loop. CONTRACT: remove only the known obsolete Trainlog-owned override;
# preserve every unrecognized administrator customization. INVARIANT: the
# effective ExecStart always owns sync-once plus both transport adapters.
LEGACY_ROLLOUT="$HOME/.config/systemd/user/trainlog-syncd.service.d/rollout.conf"
if [[ -f "$LEGACY_ROLLOUT" ]] &&
grep -Fxq 'ExecStart=%h/.local/bin/trainlog-syncd --interval 3' "$LEGACY_ROLLOUT"; then
rm -f -- "$LEGACY_ROLLOUT"
fi
ln -sfn "$SYNC_ONCE" "$HOME/.local/bin/trainlog-sync-once" ln -sfn "$SYNC_ONCE" "$HOME/.local/bin/trainlog-sync-once"
ln -sfn "$DAEMON" "$HOME/.local/bin/trainlog-syncd" ln -sfn "$DAEMON" "$HOME/.local/bin/trainlog-syncd"
ln -sfn "$BT_AGENT" "$HOME/.local/bin/trainlog-btd" ln -sfn "$BT_AGENT" "$HOME/.local/bin/trainlog-btd"

View file

@ -328,7 +328,13 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
) )
if local is not None and not remote_descends_from_local and medication["parent_revision_id"] != local[0]: if local is not None and not remote_descends_from_local and medication["parent_revision_id"] != local[0]:
fail("concurrent medication revision") fail("concurrent medication revision")
if local is None and medication["parent_revision_id"] is not None: fail("unknown medication parent") # CONTRACT: this artifact is a current-state companion, not a revision
# log. A fresh peer may seed a non-root tip only when the validated
# complete ancestry accompanies it; legacy payloads without ancestry
# retain the strict unknown-parent rejection.
if (local is None and medication["parent_revision_id"] is not None and
"ancestry" not in medication):
fail("unknown medication parent")
if local is not None and local[1] and not medication["deleted"]: fail("medication resurrection") if local is not None and local[1] and not medication["deleted"]: fail("medication resurrection")
if local is None: if local is None:
db.execute("INSERT INTO sleep_medications VALUES(?,?,?,?,?)", (medication["medication_id"], medication["created_at"], medication["updated_at"], medication["revision_id"], int(medication["deleted"]))) db.execute("INSERT INTO sleep_medications VALUES(?,?,?,?,?)", (medication["medication_id"], medication["created_at"], medication["updated_at"], medication["revision_id"], int(medication["deleted"])))
@ -379,7 +385,13 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
remote_descends_from_local = local is not None and local[0] in item.get("ancestry", [])[1:] remote_descends_from_local = local is not None and local[0] in item.get("ancestry", [])[1:]
if local is not None and not remote_descends_from_local and item["parent_revision_id"] != local[0]: if local is not None and not remote_descends_from_local and item["parent_revision_id"] != local[0]:
fail("concurrent sleep diary revision") fail("concurrent sleep diary revision")
if local is None and item["parent_revision_id"] is not None: # WHY: one-tap Sleep creates several local revisions before its first
# synchronization. The current snapshot carries their bounded lineage
# by identity, so a new desktop must be able to seed the tip without
# inventing historical payloads. INVARIANT: missing ancestry still
# rejects a non-root first snapshot.
if (local is None and item["parent_revision_id"] is not None and
"ancestry" not in item):
fail("sleep diary revision has an unknown parent") fail("sleep diary revision has an unknown parent")
if local is not None and local[1] and not item["deleted"]: if local is not None and local[1] and not item["deleted"]:
fail("sleep diary deletion cannot be resurrected") fail("sleep diary deletion cannot be resurrected")

View file

@ -30,6 +30,7 @@ MAX_DIAGNOSTIC = 1024
PHASES = { PHASES = {
"requested", "requested",
"waiting_android_publication", "waiting_android_publication",
"android_archive_ack_observed",
"running", "running",
"local_import_committed", "local_import_committed",
"published", "published",
@ -297,6 +298,7 @@ def read_worker(
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
allowed = [ allowed = [
"waiting_android_publication", "waiting_android_publication",
"android_archive_ack_observed",
"running", "running",
"local_import_committed", "local_import_committed",
"published", "published",
@ -338,6 +340,7 @@ def read_worker(
"inbound_generation_id", "inbound_generation_id",
"outbound_generation_id", "outbound_generation_id",
"manifest_sha256", "manifest_sha256",
"recovered_acknowledgements",
} }
or value.get("version") != 1 or value.get("version") != 1
or value.get("run_id") != run_id or value.get("run_id") != run_id