From c15e5824e309338358b9f8d1026c3423c3d16e4d Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 11 Jun 2026 14:16:54 +0200 Subject: [PATCH 1/4] feat: add resetScores devtools command --- .../java/to/bitkit/dev/DevToolsProvider.kt | 20 +++++++++++++ .../to/bitkit/repositories/LightningRepo.kt | 28 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt index acfbfc628..6cee0e817 100644 --- a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt +++ b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt @@ -64,6 +64,7 @@ private sealed interface DevCommand { ProbeInvoice.METHOD -> ProbeInvoice.parse(arg) ProbeNode.METHOD -> ProbeNode.parse(arg) ProbeReadiness.METHOD -> ProbeReadiness + ResetScores.METHOD -> ResetScores else -> null } } @@ -172,6 +173,21 @@ private sealed interface DevCommand { override suspend fun execute(deps: DevToolsProvider.Dependencies): DevResult = DevResult.ProbeReadiness.from(deps.lightningRepo().probeReadiness()) } + + data object ResetScores : DevCommand { + const val METHOD = "resetScores" + + override suspend fun execute(deps: DevToolsProvider.Dependencies): DevResult { + Logger.info("Resetting pathfinding scores via devtools", context = TAG) + return deps.lightningRepo().resetPathfindingScores().fold( + onSuccess = { DevResult.Ack() }, + onFailure = { + Logger.error("Failed to reset pathfinding scores", it, context = TAG) + DevResult.Error(it.message) + }, + ) + } + } } @Serializable @@ -183,6 +199,8 @@ private sealed interface DevResult { @Serializable data class Invoice(val bolt11: String) : DevResult + @Serializable data class Ack(val success: Boolean = true) : DevResult + @Serializable data class ProbeSuccess( val success: Boolean = true, @@ -224,6 +242,7 @@ private sealed interface DevResult { val graphNodeCount: Int? = null, val graphChannelCount: Int? = null, val latestRgsSyncTimestamp: ULong? = null, + val latestPathfindingScoresSyncTimestamp: ULong? = null, ) : DevResult { companion object { fun from(readiness: NodeProbeReadiness) = ProbeReadiness( @@ -241,6 +260,7 @@ private sealed interface DevResult { graphNodeCount = readiness.graphNodeCount, graphChannelCount = readiness.graphChannelCount, latestRgsSyncTimestamp = readiness.latestRgsSyncTimestamp, + latestPathfindingScoresSyncTimestamp = readiness.latestPathfindingScoresSyncTimestamp, ) } } diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 4bbc4c9a6..bb359d9f4 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1620,9 +1620,34 @@ class LightningRepo @Inject constructor( graphNodeCount = graph?.nodeCount, graphChannelCount = graph?.channelCount, latestRgsSyncTimestamp = graph?.latestRgsSyncTimestamp, + latestPathfindingScoresSyncTimestamp = state.nodeStatus?.latestPathfindingScoresSyncTimestamp, syncHealthy = state.isSyncHealthy, ) } + + suspend fun resetPathfindingScores(walletIndex: Int = 0): Result = withContext(bgDispatcher) { + Logger.info("Resetting pathfinding scores", context = TAG) + + waitForNodeToStop().onFailure { return@withContext Result.failure(it) } + stop().onFailure { + Logger.error("Failed to stop node during pathfinding scores reset", it, context = TAG) + return@withContext Result.failure(it) + } + + runCatching { + vssBackupClientLdk.setup(walletIndex).getOrThrow() + vssBackupClientLdk.deleteObject(VSS_KEY_SCORER).getOrThrow() + vssBackupClientLdk.deleteObject(VSS_KEY_EXTERNAL_SCORES_CACHE).getOrThrow() + }.onFailure { + Logger.error("Failed to delete pathfinding scores from VSS", it, context = TAG) + start(walletIndex = walletIndex, shouldRetry = false) + return@withContext Result.failure(it) + } + + start(walletIndex = walletIndex, shouldRetry = false).onSuccess { + Logger.info("Pathfinding scores reset", context = TAG) + } + } // endregion suspend fun restartNode(): Result = withContext(bgDispatcher) { @@ -1642,6 +1667,8 @@ class LightningRepo @Inject constructor( companion object { private const val TAG = "LightningRepo" private const val LENGTH_CHANNEL_ID_PREVIEW = 10 + private const val VSS_KEY_SCORER = "scorer" + private const val VSS_KEY_EXTERNAL_SCORES_CACHE = "external_pathfinding_scores_cache" private const val MS_SYNC_LOOP_DEBOUNCE = 500L private const val SYNC_RETRY_DELAY_MS = 15_000L private val CHANNELS_USABLE_TIMEOUT = 15.seconds @@ -1702,6 +1729,7 @@ data class ProbeReadiness( val graphNodeCount: Int?, val graphChannelCount: Int?, val latestRgsSyncTimestamp: ULong?, + val latestPathfindingScoresSyncTimestamp: ULong?, val syncHealthy: Boolean, ) { val ready: Boolean From 0c33ee05fac273d0c4df7309675cfdabfb8cafe1 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 11 Jun 2026 15:01:49 +0200 Subject: [PATCH 2/4] fix: harden resetPathfindingScores error path Co-authored-by: Cursor --- app/src/main/java/to/bitkit/repositories/LightningRepo.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index bb359d9f4..6f6604cf7 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1635,12 +1635,18 @@ class LightningRepo @Inject constructor( } runCatching { + val lifecycleState = _lightningState.value.nodeLifecycleState + check(lifecycleState == NodeLifecycleState.Stopped) { + "Node lifecycle changed to '$lifecycleState' during pathfinding scores reset" + } vssBackupClientLdk.setup(walletIndex).getOrThrow() vssBackupClientLdk.deleteObject(VSS_KEY_SCORER).getOrThrow() vssBackupClientLdk.deleteObject(VSS_KEY_EXTERNAL_SCORES_CACHE).getOrThrow() }.onFailure { Logger.error("Failed to delete pathfinding scores from VSS", it, context = TAG) - start(walletIndex = walletIndex, shouldRetry = false) + start(walletIndex = walletIndex, shouldRetry = false).onFailure { startError -> + Logger.error("Failed to restart node after pathfinding scores reset failure", startError, context = TAG) + } return@withContext Result.failure(it) } From c534c742b35883ba3556a5d05d235c5340237112 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 11 Jun 2026 15:18:30 +0200 Subject: [PATCH 3/4] fix: return post-delete timestamp from scores reset Co-authored-by: Cursor --- .../debug/java/to/bitkit/dev/DevToolsProvider.kt | 4 ++-- .../java/to/bitkit/repositories/LightningRepo.kt | 16 ++++++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt index 6cee0e817..ea61fd726 100644 --- a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt +++ b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt @@ -180,7 +180,7 @@ private sealed interface DevCommand { override suspend fun execute(deps: DevToolsProvider.Dependencies): DevResult { Logger.info("Resetting pathfinding scores via devtools", context = TAG) return deps.lightningRepo().resetPathfindingScores().fold( - onSuccess = { DevResult.Ack() }, + onSuccess = { DevResult.Ack(timestamp = it) }, onFailure = { Logger.error("Failed to reset pathfinding scores", it, context = TAG) DevResult.Error(it.message) @@ -199,7 +199,7 @@ private sealed interface DevResult { @Serializable data class Invoice(val bolt11: String) : DevResult - @Serializable data class Ack(val success: Boolean = true) : DevResult + @Serializable data class Ack(val success: Boolean = true, val timestamp: Long? = null) : DevResult @Serializable data class ProbeSuccess( diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 6f6604cf7..13b7ff389 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1625,7 +1625,11 @@ class LightningRepo @Inject constructor( ) } - suspend fun resetPathfindingScores(walletIndex: Int = 0): Result = withContext(bgDispatcher) { + /** + * Returns the device epoch seconds captured after the VSS deletes and before the node restart, + * so callers can require any scores sync timestamp to be strictly newer to prove a post-reset download. + */ + suspend fun resetPathfindingScores(walletIndex: Int = 0): Result = withContext(bgDispatcher) { Logger.info("Resetting pathfinding scores", context = TAG) waitForNodeToStop().onFailure { return@withContext Result.failure(it) } @@ -1650,9 +1654,13 @@ class LightningRepo @Inject constructor( return@withContext Result.failure(it) } - start(walletIndex = walletIndex, shouldRetry = false).onSuccess { - Logger.info("Pathfinding scores reset", context = TAG) - } + val resetAtSecs = System.currentTimeMillis() / 1000 + + start(walletIndex = walletIndex, shouldRetry = false) + .map { resetAtSecs } + .onSuccess { + Logger.info("Pathfinding scores reset at '$resetAtSecs'", context = TAG) + } } // endregion From 7956098e8cf3add5a9ac75854233c6e2012064a6 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 11 Jun 2026 16:26:58 +0200 Subject: [PATCH 4/4] refactor: use nowMillis ext for reset timestamp Co-authored-by: Cursor --- AGENTS.md | 1 + app/src/main/java/to/bitkit/repositories/LightningRepo.kt | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 44f2bc3cc..40c58f347 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -178,6 +178,7 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { - ALWAYS check existing code patterns before implementing new features - USE existing extensions and utilities rather than creating new ones - ALWAYS use or create `Context` extension properties in `ext/Context.kt` instead of raw `context.getSystemService()` casts +- NEVER use `System.currentTimeMillis()`, use time helpers from `ext/DateTime.kt` instead (e.g. `nowMillis()`, `Clock.nowMs()`) — they accept a `Clock` and are unit-testable - ALWAYS apply the YAGNI (You Ain't Gonna Need It) principle for new code - ALWAYS reuse existing constants - ALWAYS ensure a method exist before calling it diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 13b7ff389..e329e2b71 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -61,6 +61,7 @@ import to.bitkit.di.BgDispatcher import to.bitkit.env.Defaults import to.bitkit.env.Env import to.bitkit.ext.getSatsPerVByteFor +import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp import to.bitkit.ext.toPeerDetailsList import to.bitkit.ext.totalNextOutboundHtlcLimitSats @@ -99,6 +100,7 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime @Singleton @Suppress("LongParameterList", "TooManyFunctions", "LargeClass") @@ -1629,6 +1631,7 @@ class LightningRepo @Inject constructor( * Returns the device epoch seconds captured after the VSS deletes and before the node restart, * so callers can require any scores sync timestamp to be strictly newer to prove a post-reset download. */ + @OptIn(ExperimentalTime::class) suspend fun resetPathfindingScores(walletIndex: Int = 0): Result = withContext(bgDispatcher) { Logger.info("Resetting pathfinding scores", context = TAG) @@ -1654,7 +1657,7 @@ class LightningRepo @Inject constructor( return@withContext Result.failure(it) } - val resetAtSecs = System.currentTimeMillis() / 1000 + val resetAtSecs = nowMillis() / 1000 start(walletIndex = walletIndex, shouldRetry = false) .map { resetAtSecs }