Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
6654e4e
test: cover read-only restored lifecycle projection
austinywang Sep 16, 2026
37ccc69
fix: keep restore snapshot reconciliation read-only and offload index…
austinywang Sep 16, 2026
40daffd
fix: await resume evidence before the startup completion save
austinywang Sep 16, 2026
ab3fa56
Merge remote-tracking branch 'origin/main' into issue-12793-restore-r…
austinywang Sep 17, 2026
1e8f4d0
test: reproduce restore binding loss before process observation
austinywang Sep 19, 2026
1c907f8
Merge remote-tracking branch 'origin/main' into issue-12793-restore-r…
austinywang Sep 19, 2026
ac26501
fix: preserve restored process bindings until observation or expiry
austinywang Sep 19, 2026
117edf2
fix: address restore review invariants
austinywang Sep 20, 2026
4bc20b0
fix: quote restore observation project path
austinywang Sep 20, 2026
56e0dd7
fix: import workspace shell state for restore helper
austinywang Sep 20, 2026
7cca5d2
fix: avoid duplicate restore binding local
austinywang Sep 20, 2026
4ff6bdd
Merge remote-tracking branch 'origin/main' into issue-12793-restore-r…
austinywang Sep 21, 2026
5af0f3d
fix: address review freshness and package boundaries
austinywang Sep 21, 2026
f8bd716
fix: preserve fresh-index freeze invariant
austinywang Sep 21, 2026
07274e7
fix: expose restore observation with public scalar types
austinywang Sep 21, 2026
9d14169
fix: expose SSH restore observation limit to extension
austinywang Sep 21, 2026
96f31a9
Merge remote-tracking branch 'origin/main' into issue-12793-restore-r…
austinywang Sep 21, 2026
596f617
fix: use lifecycle signals for restore retention
austinywang Sep 21, 2026
21f676e
fix: unwrap completed recovery indexes
austinywang Sep 21, 2026
c3a2721
fix: unwrap recovered binding index
austinywang Sep 21, 2026
ff8f2a6
fix: keep retry scheduling outside defer exits
austinywang Sep 21, 2026
53faa57
fix: use concrete recovery binding map
austinywang Sep 21, 2026
6f4f8c0
fix: clear restore observation at shell prompt
austinywang Sep 21, 2026
b9ced65
fix: tie restore cleanup to fresh lifecycle evidence
austinywang Sep 21, 2026
e038169
fix: retain restore protection on rejected replay
austinywang Sep 21, 2026
0999678
Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue…
austinywang Sep 21, 2026
aa390fc
fix: scope process save coordination
austinywang Sep 21, 2026
9c0f415
Merge origin/main into restore resume lifecycle branch
austinywang Sep 25, 2026
734919e
Fix restore lifecycle integration after main merge
austinywang Sep 25, 2026
15bd488
Preserve resume bindings when process evidence is unavailable
austinywang Sep 25, 2026
48bbcd8
Merge origin/main into restore resume lifecycle branch
austinywang Sep 28, 2026
1aedc13
Fix resume binding equality after legacy field removal
austinywang Sep 28, 2026
4e166cd
Update restore projection tests for current lifecycle API
austinywang Sep 28, 2026
699b98e
Clean up remote-close test shells before later suites
austinywang Sep 28, 2026
066ad28
Merge origin/main into restore resume lifecycle branch
austinywang Sep 28, 2026
6f28153
Merge main (ce5cb45d55d9) into issue-12793-restore-resume-lifecycle
austinywang Sep 28, 2026
adeabe3
Bump Bonsplit for main split auto-layout API
austinywang Sep 28, 2026
bb4060a
Merge main (ecba57ac7195) into issue-12793-restore-resume-lifecycle
austinywang Sep 30, 2026
7c5c5a9
test: bound restore observation and windowless freeze retries
austinywang Sep 30, 2026
981d207
Bound restore observation and windowless freeze retries
austinywang Sep 30, 2026
238cacd
Restore Bonsplit pointer rewound by #15747
austinywang Sep 30, 2026
0883b13
Start restore observation window when the runtime spawns
austinywang Sep 30, 2026
bd84160
Fix test compilation after main merge
austinywang Sep 30, 2026
cdec6e6
Merge main (3b29735c8182) into issue-12793-restore-resume-lifecycle
austinywang Sep 30, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/// Owns the explicit lifecycle observation state for a process-backed restore binding.
///
/// A restored binding is protected from empty scans while its terminal has not
/// yet spawned, because its restore command cannot have run. Once the runtime
/// spawns, protection lasts ``observationWindow``: a binding whose command never
/// launches, or whose shell never reports a prompt transition, then retires like
/// any unobserved binding instead of surviving every relaunch.
public struct RestoredProcessDetectionObservation: Equatable, Sendable {
/// Upper bound for a spawned restored shell to run its queued command.
///
/// Measured on the suspending clock so machine sleep does not consume it.
public static let observationWindow: Duration = .seconds(300)

private enum Phase: Equatable, Sendable {
case awaitingRuntimeSpawn
case runtimeSpawned(at: SuspendingClock.Instant)
}

private var phase: Phase?

/// Creates a cleared observation policy.
public init() {
phase = nil
}

/// Arms observation until the runtime spawns and evidence arrives or the window elapses.
public mutating func arm() {
phase = .awaitingRuntimeSpawn
}

/// Starts the observation window once the restored terminal runtime exists.
///
/// - Returns: Whether the observation state changed.
@discardableResult
public mutating func recordRuntimeSpawn(at now: SuspendingClock.Instant = .now) -> Bool {
guard phase == .awaitingRuntimeSpawn else { return false }
phase = .runtimeSpawned(at: now)
return true
}

/// Returns whether the restore binding remains protected from an empty scan.
public func preserves(at now: SuspendingClock.Instant = .now) -> Bool {
switch phase {
case nil:
return false
case .awaitingRuntimeSpawn:
return true
case .runtimeSpawned(let spawnedAt):
return spawnedAt.duration(to: now) < Self.observationWindow
}
}

/// Clears observation after authoritative process or shell evidence.
public mutating func clear() {
phase = nil
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Testing
@testable import CmuxWorkspaces

@Suite struct RestoredProcessDetectionObservationTests {
@Test func armedLifecycleStatePreservesUntilCleared() {
var observation = RestoredProcessDetectionObservation()
#expect(!observation.preserves())
observation.arm()
#expect(observation.preserves())
observation.clear()
#expect(!observation.preserves())
}

@Test func observationWaitsForTheRuntimeSpawnBeforeItsWindowStarts() {
// A paced or unviewed restored terminal may spawn long after restore;
// its command cannot have run, so the window must not start early.
var observation = RestoredProcessDetectionObservation()
observation.arm()
let spawnedAt = SuspendingClock.now.advanced(by: .seconds(3_600))
#expect(observation.preserves(at: spawnedAt))
// `recordRuntimeSpawn` is mutating, so each result is read outside the test macros.
let firstSpawn = observation.recordRuntimeSpawn(at: spawnedAt)
let repeatedSpawn = observation.recordRuntimeSpawn(at: spawnedAt.advanced(by: .seconds(10)))
#expect(firstSpawn)
#expect(!repeatedSpawn)
let window = RestoredProcessDetectionObservation.observationWindow
#expect(observation.preserves(at: spawnedAt.advanced(by: window - .seconds(1))))
#expect(!observation.preserves(at: spawnedAt.advanced(by: window)))
}

@Test func aClearedObservationIgnoresALateSpawn() {
var observation = RestoredProcessDetectionObservation()
observation.arm()
observation.clear()
let lateSpawn = observation.recordRuntimeSpawn()
#expect(!lateSpawn)
#expect(!observation.preserves())
}
}
113 changes: 71 additions & 42 deletions Sources/AppDelegate+RecoverableMainWindowRoutes.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import AppKit
import CmuxTerminalCore
import CmuxTerminal

// The retire sweep is the MainWindowRouteRetiring witness: terminal topology
// changes prompt a coalesced lifecycle audit through the seam instead of the
// registry reaching up to AppDelegate.shared.
extension AppDelegate: MainWindowRouteRetiring {}

extension AppDelegate {
typealias MainWindowSessionPersistenceRoute = (
windowId: UUID,
Expand Down Expand Up @@ -394,17 +392,63 @@ extension AppDelegate {
/// routing call stack. Identity checks on both sides of the suspension make
/// a reattached or explicitly closed route win over the deferred freeze.
private func scheduleWindowlessRecoverableMainWindowRouteFreeze(
_ route: RecoverableMainWindowRoute
_ route: RecoverableMainWindowRoute,
retryAttempt: Int = 0
) {
let routeTTYDeviceBindings = currentSurfaceTTYDeviceBindings(for: route)
let windowId = route.windowId
let routeIdentity = ObjectIdentifier(route)
let taskToken = UUID()
let onWorkerCreated: @MainActor @Sendable (Task<ProcessDetectedResumeIndexes, Never>) -> Void = {
[weak self, weak lifecycleCoordinator = mainWindowLifecycleCoordinator] worker in
lifecycleCoordinator?.retainWindowlessRecoveryResumeIndexesWorker(
worker,
onCompleted: { [weak self, weak lifecycleCoordinator] _ in
guard let self, let lifecycleCoordinator else { return }
for retry in lifecycleCoordinator.consumeWindowlessRouteFreezeRetries() {
guard let route = lifecycleCoordinator.orphanedRoute(windowId: retry.windowId),
route.window == nil,
route.frozenWindowSnapshot == nil else {
continue
}
self.scheduleWindowlessRecoverableMainWindowRouteFreeze(
route,
retryAttempt: retry.attempt
)
}
}
)
}
let task = Task { @MainActor [weak self] in
var nextRetryAttempt: Int?
defer {
let workerIsRunning = self?.mainWindowLifecycleCoordinator
.isWindowlessRecoveryResumeIndexesWorkerRunning() == true
self?.mainWindowLifecycleCoordinator.releaseWindowlessRouteFreezeTask(
windowId: windowId,
token: taskToken
token: taskToken,
retryAttemptWhenWorkerCompletes: workerIsRunning ? nextRetryAttempt : nil
)
if let nextRetryAttempt, !workerIsRunning,
let self,
let currentRoute = self.mainWindowLifecycleCoordinator.orphanedRoute(windowId: windowId),
ObjectIdentifier(currentRoute) == routeIdentity,
currentRoute.window == nil,
currentRoute.frozenWindowSnapshot == nil {
self.scheduleWindowlessRecoverableMainWindowRouteFreeze(
currentRoute,
retryAttempt: nextRetryAttempt
)
}
}
if retryAttempt > 0 {
// Back off before rescanning so an index that stays incomplete
// (an undecodable hook store, a scan slower than its deadline)
// cannot drive back-to-back process/filesystem scans.
try? await Task.sleep(
for: MainWindowLifecycleCoordinator.windowlessRouteFreezeRetryDelay(
attempt: retryAttempt
)
)
}
guard !Task.isCancelled else { return }
Expand Down Expand Up @@ -442,18 +486,14 @@ extension AppDelegate {
return
}
let lifecycleCoordinator = self?.mainWindowLifecycleCoordinator
let resumeIndexes = await self?.mainWindowLifecycleCoordinator
.loadWindowlessRecoveryResumeIndexes(
ttyDeviceBindings: ttyDeviceBindings
) { bindings in
await ProcessDetectedResumeIndexes.loadFreshWithDeadline(
ttyDeviceBindings: bindings,
onWorkerCreated: { [weak lifecycleCoordinator] worker in
lifecycleCoordinator?
.retainWindowlessRecoveryResumeIndexesWorker(worker)
}
)
}
let resumeIndexes = await lifecycleCoordinator?.loadWindowlessRecoveryResumeIndexes(
ttyDeviceBindings: ttyDeviceBindings
) { bindings in
await ProcessDetectedResumeIndexes.loadFreshWithDeadline(
ttyDeviceBindings: bindings,
onWorkerCreated: onWorkerCreated
)
}
guard !Task.isCancelled,
self?.mainWindowLifecycleCoordinator.orphanedRoute(
windowId: windowId
Expand All @@ -463,38 +503,25 @@ extension AppDelegate {
self?.windowForMainWindowId(windowId) == nil else {
return
}
// A timed-out fresh scan must retain the last cached agent projection;
// only its process-detected surface bindings are unavailable.
let restorableAgentIndex: RestorableAgentSessionIndex
if let detectedAgentIndex = resumeIndexes?.restorableAgentIndex {
restorableAgentIndex = detectedAgentIndex
} else if let cachedAgentIndex = SharedLiveAgentIndex.shared.index {
restorableAgentIndex = cachedAgentIndex
} else {
// A cold cache still has a persisted projection. Refresh it
// off the interactive path, then fall back to that persisted
// projection if the ownership-sensitive refresh deadline is
// unavailable. Never substitute an empty index merely because
// a bounded refresh timed out before this irreversible freeze.
if let refreshedAgentIndex = await SharedLiveAgentIndex.shared
.indexRefreshingNow() {
restorableAgentIndex = refreshedAgentIndex
} else {
let fallbackTask = Task.detached(priority: .utility) {
RestorableAgentSessionIndex.load()
}
restorableAgentIndex = await fallbackTask.value
}
// Windowless teardown is irreversible. A timeout or incomplete
// process scan leaves the live route intact for a bounded retry;
// once the budget is spent the quit/power-off save owns the route.
guard let resumeIndexes,
resumeIndexes.isFresh,
resumeIndexes.restorableAgentIndex.isComplete else {
nextRetryAttempt = MainWindowLifecycleCoordinator
.nextWindowlessRouteFreezeRetryAttempt(after: retryAttempt)
return
}
Comment thread
austinywang marked this conversation as resolved.
guard !Task.isCancelled else { return }
let detectedSurfaceResumeBindingIndex = resumeIndexes?.surfaceResumeBindingIndex
let detectedSurfaceResumeBindingIndex = resumeIndexes.surfaceResumeBindingIndex
if let currentRoute = self?.mainWindowLifecycleCoordinator.orphanedRoute(
windowId: windowId
), ObjectIdentifier(currentRoute) == routeIdentity {
self?.freezeWindowlessRecoverableMainWindowRoute(
currentRoute,
restorableAgentIndex: restorableAgentIndex,
surfaceResumeBindingIndex: detectedSurfaceResumeBindingIndex?.isEmpty == false
restorableAgentIndex: resumeIndexes.restorableAgentIndex,
surfaceResumeBindingIndex: detectedSurfaceResumeBindingIndex.isEmpty == false
? detectedSurfaceResumeBindingIndex
: nil
)
Expand Down Expand Up @@ -615,7 +642,9 @@ extension AppDelegate {
) -> [MainWindowPersistenceRouteSnapshot] {
let windowsByWindowId = currentMainWindowsByWindowId()
let maximumRecoverableRoutes = availableWindowlessPersistenceSlots()
if freezeWindowlessRoutes, let suppliedRestorableAgentIndex {
if freezeWindowlessRoutes,
let suppliedRestorableAgentIndex,
suppliedRestorableAgentIndex.isComplete {
var candidateOrphanedRoutes: [RecoverableMainWindowRoute] = []
for route in mainWindowLifecycleCoordinator.orphanedRoutes() {
guard let snapshot = recoverableMainWindowPersistenceRouteSnapshot(
Expand Down
Loading
Loading