Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ All notable changes to trifola are documented here. The format follows
- **Warm-launch session hydration** — cached SQLite accumulator payloads decode concurrently in
bounded batches, so a valid index publishes before the background scan without changing session
contents or numbering.
- **Sessions first-snapshot stall** — the Sessions view no longer sits in “Preparing session index”
for the length of the validation scan after the corpus hydrates. The first lineage snapshot paints
within a few seconds via stale-while-revalidate — the newest completed forest serves immediately,
a fresh resolve is never cancelled by a scan batch and swaps in silently, and revalidation runs at
background priority — leaving the resolved lineage and session counts unchanged.

## [0.4.0] - 2026-07-18

Expand Down
12 changes: 12 additions & 0 deletions Sources/Trifola/AppServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,18 @@ final class AppServices: ObservableObject {
.sink { [weak self] _ in self?.objectWillChange.send() }
.store(in: &forwarders)

// The session list must rebuild when the STORE publishes, not when
// `refreshNow` returns: a warm launch hydrates the corpus in ~3s but
// the validation scan keeps refreshNow awaiting for its full duration,
// and completion-ordered rebuilds left the first Sessions snapshot
// starving behind the whole scan.
sessions.$sessions
.dropFirst()
.throttle(for: .milliseconds(500), scheduler: DispatchQueue.main,
latest: true)
.sink { [weak self] _ in self?.refreshNavigationSnapshots() }
.store(in: &forwarders)

// Each committed FTS batch is immediately queryable through WAL. Refresh
// the detached search projection at a bounded cadence so a first-run query
// gains partial results without waiting for the final batch.
Expand Down
89 changes: 68 additions & 21 deletions Sources/Trifola/NavigationSnapshotStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,11 @@ final class NavigationSnapshotStore: ObservableObject {
(source: LineageSource, resolution: SessionLineageResolution)?
private var lineageInFlight:
(source: LineageSource, task: Task<SessionLineageResolution?, Never>)?
/// Newest completed resolution regardless of source — the
/// stale-while-revalidate forest. On a warm launch the hydrated corpus is
/// ≈ final, so serving it while batches revalidate is honest; the fresh
/// forest swaps in silently when its resolve completes.
private var lastLineageResolution: SessionLineageResolution?
private var corpusGeneration = 0
private var fleetGeneration = 0
private var deadlineGeneration = 0
Expand Down Expand Up @@ -237,10 +242,11 @@ final class NavigationSnapshotStore: ObservableObject {
if lineageMemo?.source != lineageSource {
lineageMemo = nil
}
if let pending = lineageInFlight, pending.source != lineageSource {
pending.task.cancel()
lineageInFlight = nil
}
// An in-flight resolve for an older source is NOT cancelled: during a
// validation scan every batch bumps the revision, and cancel-restart
// starved the first snapshot for the whole scan. The old resolve
// completes as the stale-while-revalidate forest; a fresh resolve
// follows once it lands.
inputs = Inputs(
sessions: sessions,
attentionSignals: attentionSignals,
Expand Down Expand Up @@ -385,6 +391,27 @@ final class NavigationSnapshotStore: ObservableObject {
}
}

/// A lineage resolve finished. Record it as the freshest forest, memoize
/// when it still matches the live inputs, and re-schedule so a snapshot
/// that painted a stale forest swaps to this one silently. When inputs
/// moved on mid-resolve, the re-schedule starts the follow-up resolve.
private func lineageResolveCompleted(
source: LineageSource,
task: Task<SessionLineageResolution?, Never>,
resolution: SessionLineageResolution?
) {
if lineageInFlight?.task == task { lineageInFlight = nil }
guard let resolution else { return }
lastLineageResolution = resolution
if let inputs, LineageSource(
sessionsRevision: inputs.sessionsRevision,
evidenceRevision: inputs.lineageEvidenceRevision,
includeHeuristics: inputs.showHeuristicLinks) == source {
lineageMemo = (source: source, resolution: resolution)
}
scheduleSessions()
}

Comment on lines +394 to +414

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

A failed resolve never retries itself.

When resolution is nil (the try? in the detached task swallowed a throw), lineageInFlight is cleared but scheduleSessions() is never called — the early return at Line 404 skips it. Nothing else will re-arm a resolve attempt until some unrelated external event (a session/filter change) happens to call rebuild()/updateSessionFilter() again. On the very first launch, if this first-ever resolve fails and the corpus is otherwise idle, Sessions can stay stuck exactly the way this PR is fixing.

🐛 Proposed fix: always re-schedule, even on failure
     private func lineageResolveCompleted(
         source: LineageSource,
         task: Task<SessionLineageResolution?, Never>,
         resolution: SessionLineageResolution?
     ) {
         if lineageInFlight?.task == task { lineageInFlight = nil }
-        guard let resolution else { return }
-        lastLineageResolution = resolution
-        if let inputs, LineageSource(
-            sessionsRevision: inputs.sessionsRevision,
-            evidenceRevision: inputs.lineageEvidenceRevision,
-            includeHeuristics: inputs.showHeuristicLinks) == source {
-            lineageMemo = (source: source, resolution: resolution)
+        if let resolution {
+            lastLineageResolution = resolution
+            if let inputs, LineageSource(
+                sessionsRevision: inputs.sessionsRevision,
+                evidenceRevision: inputs.lineageEvidenceRevision,
+                includeHeuristics: inputs.showHeuristicLinks) == source {
+                lineageMemo = (source: source, resolution: resolution)
+            }
         }
         scheduleSessions()
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// A lineage resolve finished. Record it as the freshest forest, memoize
/// when it still matches the live inputs, and re-schedule so a snapshot
/// that painted a stale forest swaps to this one silently. When inputs
/// moved on mid-resolve, the re-schedule starts the follow-up resolve.
private func lineageResolveCompleted(
source: LineageSource,
task: Task<SessionLineageResolution?, Never>,
resolution: SessionLineageResolution?
) {
if lineageInFlight?.task == task { lineageInFlight = nil }
guard let resolution else { return }
lastLineageResolution = resolution
if let inputs, LineageSource(
sessionsRevision: inputs.sessionsRevision,
evidenceRevision: inputs.lineageEvidenceRevision,
includeHeuristics: inputs.showHeuristicLinks) == source {
lineageMemo = (source: source, resolution: resolution)
}
scheduleSessions()
}
/// A lineage resolve finished. Record it as the freshest forest, memoize
/// when it still matches the live inputs, and re-schedule so a snapshot
/// that painted a stale forest swaps to this one silently. When inputs
/// moved on mid-resolve, the re-schedule starts the follow-up resolve.
private func lineageResolveCompleted(
source: LineageSource,
task: Task<SessionLineageResolution?, Never>,
resolution: SessionLineageResolution?
) {
if lineageInFlight?.task == task { lineageInFlight = nil }
if let resolution {
lastLineageResolution = resolution
if let inputs, LineageSource(
sessionsRevision: inputs.sessionsRevision,
evidenceRevision: inputs.lineageEvidenceRevision,
includeHeuristics: inputs.showHeuristicLinks) == source {
lineageMemo = (source: source, resolution: resolution)
}
}
scheduleSessions()
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Trifola/NavigationSnapshotStore.swift` around lines 394 - 414, Update
lineageResolveCompleted so scheduleSessions() is invoked even when resolution is
nil, preserving the existing cleanup and successful-resolution memoization while
removing the early return that skips retry scheduling after a failed resolve.

private func scheduleSessions(debounce: Bool = false) {
guard let inputs else { return }
sessionGeneration += 1
Expand All @@ -397,19 +424,38 @@ final class NavigationSnapshotStore: ObservableObject {
let cachedResolution = lineageMemo.flatMap {
$0.source == lineageSource ? $0.resolution : nil
}
let staleResolution = lastLineageResolution
let resolutionTask: Task<SessionLineageResolution?, Never>? = {
guard cachedResolution == nil else { return nil }
if let pending = lineageInFlight,
pending.source == lineageSource {
if let pending = lineageInFlight {
// One resolve at a time, never cancelled for a newer source —
// its completion re-schedules and the follow-up resolve picks
// up the newest inputs.
return pending.task
}
let task = Task.detached(priority: .userInitiated) {
try? SessionLineage.resolveWithIndexCancellable(
sessions: inputs.sessions,
evidence: inputs.lineageEvidence,
includeHeuristicLinks: inputs.showHeuristicLinks)
// Hop to GCD: the cooperative pool can be saturated by a
// corpus scan, and the first snapshot must not starve behind
// it. GCD over-commits, so this resolve always gets a thread.
await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
continuation.resume(
returning: try? SessionLineage.resolveWithIndexCancellable(
sessions: inputs.sessions,
evidence: inputs.lineageEvidence,
includeHeuristicLinks: inputs.showHeuristicLinks))
}
}
}
Comment on lines 436 to 449

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -B3 -A20 'func resolveWithIndexCancellable|func buildResolution' Sources/TrifolaKit/SessionLineage.swift

Repository: ss251/trifola

Length of output: 1650


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== NavigationSnapshotStore relevant methods ==\n'
ast-grep outline Sources/Trifola/NavigationSnapshotStore.swift --view expanded | sed -n '1,260p'

printf '\n== Focused slice around the detached task ==\n'
sed -n '400,480p' Sources/Trifola/NavigationSnapshotStore.swift

printf '\n== SessionLineage cancellation checks ==\n'
sed -n '405,470p' Sources/TrifolaKit/SessionLineage.swift

Repository: ss251/trifola

Length of output: 8441


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Search for lineageInFlight cancellation and task lifecycle ==\n'
rg -n -B3 -A10 'lineageInFlight|task.cancel\(|Task\.detached|withCheckedContinuation|DispatchQueue\.global' Sources/Trifola/NavigationSnapshotStore.swift Sources/TrifolaKit/SessionLineage.swift

Repository: ss251/trifola

Length of output: 15989


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- NavigationSnapshotStore.swift: task lifecycle ---'
rg -n -B3 -A12 'lineageInFlight|task.cancel\(|Task\.detached|withCheckedContinuation|DispatchQueue\.global' Sources/Trifola/NavigationSnapshotStore.swift

echo
echo '--- NavigationSnapshotStore.swift: around detached task ---'
sed -n '420,470p' Sources/Trifola/NavigationSnapshotStore.swift

echo
echo '--- SessionLineage.swift: cancellation plumbing ---'
sed -n '393,460p' Sources/TrifolaKit/SessionLineage.swift

Repository: ss251/trifola

Length of output: 15076


🌐 Web query:

Swift concurrency DispatchQueue.async Task.checkCancellation current task context cancellation does not propagate through GCD documentation

💡 Result:

Swift task cancellation does not automatically propagate into work dispatched via Grand Central Dispatch (GCD) [1][2]. Because Swift concurrency and GCD are separate systems with different execution models, there is no inherent linkage between a Swift Task's cancellation state and the execution of a block submitted to a DispatchQueue [3]. Key points regarding this behavior include: 1. System Separation: Structured concurrency relies on the Swift runtime to track parent-child task relationships and automatically propagate cancellation [4][5][6]. GCD operates independently of these task trees and does not monitor or respect the cancellation status of the surrounding Swift Task [1][3]. 2. Cooperative Cancellation: Cancellation in Swift is cooperative [2][6]. Functions such as Task.checkCancellation only function when called within an asynchronous Swift context where a task handle exists [7][8]. Since a block inside DispatchQueue.async executes on a different execution context (typically a system-managed thread pool not associated with a specific Swift Task), calling Task.checkCancellation inside it will generally not behave as expected or will fail to observe the parent task's state [2]. 3. Handling Cancellation Across Systems: If you need to stop work submitted to GCD based on a Task being cancelled, you must manually bridge the two systems [1][2]. Common approaches include: - Using a DispatchWorkItem: You can create a DispatchWorkItem and call its cancel method manually when the parent task is cancelled [9]. - Cancellation Handlers: Use withTaskCancellationHandler(operation:onCancel:) to perform custom logic, such as calling cancel on a DispatchWorkItem or a URLSessionTask, when the Swift Task is cancelled [7][2]. In summary, because GCD tasks are not part of the Swift structured concurrency tree, they are "invisible" to automatic propagation [1]. You must explicitly coordinate cancellation if you are mixing the two concurrency models [2].

Citations:


Cancellation doesn’t cross the GCD hop. resolveWithIndexCancellable()’s Task.checkCancellation() calls won’t see lineageInFlight?.task.cancel() once the work is submitted to DispatchQueue.global, so deinit can’t stop an in-flight resolve. If teardown should be able to abort this path, keep it on a Swift task or bridge cancellation into the dispatched work item.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Trifola/NavigationSnapshotStore.swift` around lines 436 - 449, The
detached Task’s GCD hop prevents cancellation from reaching
SessionLineage.resolveWithIndexCancellable, so update the task creation around
lineageInFlight?.task to keep resolution on a cancellable Swift task or
explicitly propagate cancellation into the dispatched work item. Preserve the
existing user-initiated priority and ensure deinit-triggered task cancellation
can abort the in-flight resolve.

lineageInFlight = (source: lineageSource, task: task)
Task { [weak self] in
let resolution = await task.value
await MainActor.run {
self?.lineageResolveCompleted(
source: lineageSource, task: task,
resolution: resolution)
}
}
return task
}()
sessionTask?.cancel()
Expand All @@ -424,20 +470,15 @@ final class NavigationSnapshotStore: ObservableObject {
let resolution: SessionLineageResolution
if let cachedResolution {
resolution = cachedResolution
} else if let staleResolution {
// Serve the newest completed forest NOW; the in-flight resolve
// re-schedules a silent swap when it lands.
resolution = staleResolution
} else if let resolutionTask {
// First-ever paint with nothing to show — the only case that
// waits on a resolve.
guard let completed = await resolutionTask.value else { return }
resolution = completed
Comment on lines +473 to 481

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Stale forest can transiently disagree with the live session count / Flat-mode membership.

When staleResolution is served (Line 476), projectLineage is called with the OLD forest but the CURRENT inputs.sessions/inputs.sessions.count (used for sourceCount at Line 555 and for Flat-mode's resolution.key(for: summary) lookups at Lines 900-909). During an active scan this can briefly show a session-count header that doesn't match the rendered rows, and Flat mode can silently drop newly-discovered sessions the stale forest doesn't know about yet, until the fresh resolve lands and swaps in. This self-corrects once the follow-up resolve completes, so it's low severity, but worth confirming it matches the "session counts... preserved" claim in the PR description for a live, still-scanning corpus (vs. the static benchmark corpus used for validation).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Trifola/NavigationSnapshotStore.swift` around lines 473 - 481, Update
the staleResolution branch in the resolution-selection flow so the forest is
rendered with the session snapshot it was resolved from, rather than the current
inputs.sessions. Propagate that matching snapshot through projectLineage,
sourceCount, and Flat-mode resolution.key(for:) lookups; if no matching snapshot
is available, wait for the current resolution instead of combining stale forest
data with live session membership.

guard let self else { return }
if self.inputs.map({ LineageSource(
sessionsRevision: $0.sessionsRevision,
evidenceRevision: $0.lineageEvidenceRevision,
includeHeuristics: $0.showHeuristicLinks)
}) == lineageSource {
self.lineageMemo = (
source: lineageSource,
resolution: resolution)
self.lineageInFlight = nil
}
} else {
return
}
Expand Down Expand Up @@ -522,6 +563,12 @@ final class NavigationSnapshotStore: ObservableObject {
self.sessionGeneration == generation else { return }
var state = self.sessionSearch
guard state.publish(result, for: request) else { return }
if self.sessionSearch.displayed == nil,
ProcessInfo.processInfo.environment["TRIFOLA_LAUNCH_METRICS"] == "1" {
FileHandle.standardError.write(Data(
"[launch-metric] first sessions snapshot: \(result.rows.count) rows\n"
.utf8))
}
self.sessionSearch = state
}
}
Expand Down
7 changes: 6 additions & 1 deletion Sources/TrifolaKit/Stores.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1552,7 +1552,12 @@ public final class SessionStore: ObservableObject {
claudeProjectsRoot: lineageProjectsURL,
codexImportManifestURL: lineageManifestURL)
}.value
let result = await Task.detached(priority: .userInitiated) { [weak self] in
// A warm launch already showed the hydrated corpus; revalidation is
// background work and must not starve the first snapshot's lineage
// resolve out of the cooperative pool. A cold start IS the content.
let scanPriority: TaskPriority = index.entries.isEmpty
? .userInitiated : .utility
let result = await Task.detached(priority: scanPriority) { [weak self] in
let publish: @Sendable (SessionIndex, SessionScanProgress) -> Void = {
[weak self] partial, progress in
latestProgress.withLock { $0 = progress }
Expand Down
17 changes: 16 additions & 1 deletion Tests/TrifolaKitTests/NavigationSnapshotShapeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,23 @@ struct NavigationSnapshotShapeTests {

#expect(source.contains("sessionsRevision: inputs.sessionsRevision"))
#expect(source.contains("evidenceRevision: inputs.lineageEvidenceRevision"))
#expect(source.contains("pending.source == lineageSource"))
#expect(source.contains("SessionLineage.resolveWithIndex"))
#expect(!source.contains("summaries.map { (SessionLineage.key($0), $0) }"))
}

@Test("A snapshot never starves behind lineage: stale forest serves, resolves are never cancelled for a newer source")
func lineageIsStaleWhileRevalidate() throws {
let source = try snapshotSource

// Any in-flight resolve is reused — never keyed to the current source
// and never cancelled when a scan batch bumps the revision (the
// cancel-restart pattern starved the first snapshot for a whole scan).
#expect(source.contains("if let pending = lineageInFlight {"))
#expect(!source.contains("pending.task.cancel()"))
// The newest completed forest paints immediately…
#expect(source.contains("lastLineageResolution"))
#expect(source.contains("} else if let staleResolution {"))
// …and a completed resolve re-schedules the silent swap.
#expect(source.contains("func lineageResolveCompleted"))
}
}
4 changes: 2 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,8 @@ An observability app that pegs a CPU core is disqualifying here. The pipeline is
(FSEvents) with no polling render loop; scans are off-main and incremental. Warm launch reads
SQLite on one thread while accumulator payloads decode concurrently in bounded batches, then
publishes the unchanged cached index before reconciliation. Set `TRIFOLA_LAUNCH_METRICS=1` to emit
cache-hydration and scan-reconciliation entry counts and timings to standard error. Destination
navigation is budgeted and benchmarked.
cache-hydration and scan-reconciliation entry counts and timings, plus the first Sessions snapshot's
row count, to standard error. Destination navigation is budgeted and benchmarked.

## Testing

Expand Down
Loading