diff --git a/.claude/rules/control-api.md b/.claude/rules/control-api.md index df1904773..cfaf530de 100644 --- a/.claude/rules/control-api.md +++ b/.claude/rules/control-api.md @@ -160,7 +160,7 @@ renumbering. Do not reintroduce a count anywhere. `.fullscreen`, `.minimize` - `keymap.reload`, `keymap.list`, `config.reload`, `theme.set`, `theme.list`, `restore.capture`, `restore.clear`, `restore.mode`, `version` -- `zmx.list`, `zmx.prune`, `zmx.kill`, `zmx.tree`, `zmx.attach` +- `zmx.list`, `zmx.prune`, `zmx.kill`, `zmx.reset`, `zmx.tree`, `zmx.attach` `debug.appearance` is a private `Command` case, absent from the list above, used only by `AppearanceFlipUITests`. It accepts light/dark, sets `NSApp.appearance`, posts `.agtermSystemAppearanceChanged`, echoes the effective @@ -924,6 +924,31 @@ side, and reads `lastAppliedIsDark` when bare. Refuse it outside XCUITest; provi already gone. The suppression is gated on `backedByZmx`: a requested-live launch that fell back keeps its claimed daemons while each pane runs a plain shell, so an ungated kill would close a pane that never attached to what it destroyed. +- `zmx.reset` is Help ▸ Reset Live Sessions… without the dialog, and both run `LiveResetCoordinator`. + The dispatcher refuses without `--force` before the host; the coordinator then refuses, in order, when + Live is not both the configured and the launched mode, when the listing failed, when the claim walk is + incomplete or claims a pane twice, and when no pane is orphaned or app-attributed. + `LiveReset.select` in agtermCore joins `paneClaims()` to the listing; the dialog counts distinct sessions + and the reply carries `result.liveReset` (sessions, panes, pending) plus the dialog body as `text`. + The connection thread quits only after it has written the reply to THAT request, decided from the + request being `zmx.reset` and the response being ok, never from shared state: remote workers write + other replies in parallel and must not quit the app. A reply that could not be written leaves the reset + pending for the menu or a later request. + The quit writes `live-reset.json` in the state directory only after the exit capture ran and the + checked snapshot save succeeded, then spawns the relauncher; a relauncher that cannot start removes the + marker. The next launch consumes the marker before any kill and only NARROWS it: a target is killed when + it is still claimed, still listed with the same leader pid and still orphaned; gone restores normally; + anything else is skipped. Every selected leader is polled whatever the batched kill reported, and a + survivor's pane gets neither its replay nor its durable command at that launch. + A confirmed reset arms and skips the quit alert only while Live is still both modes + (`armablePending`): a mode change after confirmation leaves the next launch unable to suppress a + survivor's ordinary seed. A launch that did not get Live discards a marker it finds without killing. + The listing and the batched kill are clamped to the remaining budget, and a batch that cannot start + before the budget expires leaves every selected pane suppressed. The Help item shows a refusal in user + words through `presentRefusal`; only a cancel is silent. + Read-back is `liveReset` on the tree top level and the `zmx list` header, omitted when nothing is + pending and no launch consumed a marker. XCUITest exemption: the command quits the app, so its + coverage is hosted and package tests plus the isolated acceptance run, like `restore.mode`. ## Remote sessions diff --git a/.claude/rules/windows.md b/.claude/rules/windows.md index 61ca029ae..cb2760aba 100644 --- a/.claude/rules/windows.md +++ b/.claude/rules/windows.md @@ -78,6 +78,12 @@ session drag are out of scope. The reason is an attribute, not a param, despite `AERegistry.h` calling it a parameter: loginwindow writes it with `AEPutAttributePtr`. Never switch that read to `paramDescriptor`. The GUI-only prompt is keep-in-sync exempt and manually verified. +- A confirmed Live sessions reset (`LiveResetCoordinator.pending`) skips the quit alert, since its own + dialog or `zmx.reset --force` was the confirmation. `AppDelegate.exitFlush` fixes the order: capture, + finalize pending closes, then the CHECKED save; only a fully saved snapshot arms the marker and the + relauncher. `LaunchOrchestration.run` in `LiveResetConsumer.swift` owns the launch side: the library's + inventory sink only stores the inventory, the consumer runs after `WindowLibrary` returns, and the + ordinary reap and the foreground resolver refresh follow it, all before any window mounts. - App-side `WindowRegistry` maps IDs to `NSWindow`. Register/unregister through `TitleProbeView`; `raise` deminiaturizes and fronts, and `close` uses `performClose` so standard teardown runs. diff --git a/CLAUDE.md b/CLAUDE.md index 4521dedc7..ee471f368 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -185,6 +185,10 @@ C-boundary concurrency before changing the bridge. teardown, and no SIGHUP reaches the process because the pty's session leader is the surviving `login`, so it outlives the app in whatever loop it was in. `hud.sh` takes the app's pid through its input file and exits on a builtin `kill -0`. +- A confirmed Live sessions reset (Help item or `zmx.reset`) is the one path that ends CLAIMED daemons at a + Live launch: `LiveResetConsumer` consumes the marker before any kill and only narrows it, then the + ordinary reap runs. Nothing arms it but the dialog or an explicit `--force` request; `control-api.md` + owns the contract. - Live-session reap follows the requested restore mode. A requested-live launch preserves claimed daemons when eligibility falls back to fresh shells; a deliberate Fresh shells or Re-run commands launch reaps every detached app daemon in the state directory. Semantic deletion kills the named daemon, while app and diff --git a/agterm/AppDelegate.swift b/agterm/AppDelegate.swift index 8fc32d41d..4cf8d35ac 100644 --- a/agterm/AppDelegate.swift +++ b/agterm/AppDelegate.swift @@ -1,11 +1,14 @@ import agtermCore import AppKit +import os @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { typealias ForegroundCommandReader = (GhosttySurfaceView, String?, ZmxForegroundResolver.Snapshot?) -> [String]? typealias ExitCapture = @MainActor @Sendable ([Session]) -> Int + private static let logger = Logger(subsystem: "com.umputun.agterm", category: "AppDelegate") + // Leaves 150 ms after the refresh's 350 ms worst case for the per-pane kernel reads. private static let exitCaptureBudget: Duration = .milliseconds(500) @@ -27,6 +30,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Injected exit policy; the configured mode is evaluated when the exit happens. var captureOnExit: ExitCapture? + /// The one-shot marker store for a confirmed Live sessions reset, in the state directory; set on scene + /// appear. Nil leaves a pending reset unarmed, and the quit proceeds as an ordinary quit. + var liveResetMarkerStore: LiveResetMarkerStore? + + /// Holds the confirmed reset between the dialog or `zmx.reset` and the quit; set on scene appear. + var liveReset: LiveResetCoordinator? + /// Strongly retains the current Dock menu's target objects so nil-sender dispatch never depends on /// AppKit's target lifetime; replaced whenever the Dock asks for a fresh menu. var dockMenuActionTargets: [DockMenuActionTarget] = [] @@ -303,6 +313,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationShouldTerminate(_: NSApplication) -> NSApplication.TerminateReply { guard !ContentView.isUITestLaunch, let library else { return .terminateNow } if QuitReason.isSystemQuit(NSAppleEventManager.shared().currentAppleEvent) { return .terminateNow } + if liveReset?.armablePending != nil { return .terminateNow } let counts = library.openCounts() guard counts.windows > 0 else { return .terminateNow } let alert = NSAlert() @@ -327,16 +338,70 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // mark terminating so per-window willClose can't zero the open-set during quit — it must survive // for the next launch's reopen-all. library?.isTerminating = true - if let library { _ = captureOnExit?(library.allOpenSessions()) } - library?.finalizeAllPendingCloses() - // flush the stores + index: cwd changes since the last structural mutation aren't auto-persisted. - library?.saveAllOpen() + if let library { + // flush the stores + index: cwd changes since the last structural mutation aren't auto-persisted. + Self.exitFlush(pending: liveReset?.armablePending, steps: ExitFlushSteps( + capture: { _ = self.captureOnExit?(library.allOpenSessions()) }, + finalize: { library.finalizeAllPendingCloses() }, + saveChecked: { library.saveAllOpenChecked() }, + save: { library.saveAllOpen() }, + arm: { selection in + guard let store = self.liveResetMarkerStore else { return false } + return Self.armLiveReset(selection, store: store) { + LiveResetRelauncher().spawn(pid: getpid(), bundle: Bundle.main.bundleURL, + stateDirectory: ProcessInfo.processInfo.environment["AGTERM_STATE_DIR"]) + } + })) + } library?.saveIndex() // flush pending debounced settings writes (a keyboard-driven opacity/blur change holds a ~0.3s save // no drag-end commit fires) so they survive ⌘Q. settingsModel?.flushPendingSaves() } + struct ExitFlushSteps { + let capture: () -> Void + let finalize: () -> Void + let saveChecked: () -> Bool + let save: () -> Void + let arm: (LiveReset.Selection) -> Bool + } + + /// The exit flush in its fixed order: capture, finalize pending closes, then save. A pending Live + /// sessions reset takes the CHECKED save and arms only when it reports every snapshot written; capture + /// is invoked, not judged, since its count is best effort. Returns whether a reset was armed. + @discardableResult + static func exitFlush(pending: LiveReset.Selection?, steps: ExitFlushSteps) -> Bool { + steps.capture() + steps.finalize() + guard let pending else { + steps.save() + return false + } + guard steps.saveChecked() else { + logger.error("live sessions reset not armed: a window snapshot did not save") + return false + } + return steps.arm(pending) + } + + /// Writes the marker, then spawns the relauncher; a relauncher that cannot start takes the marker with + /// it, so a reset is never armed for a launch nobody triggers. + static func armLiveReset(_ selection: LiveReset.Selection, store: LiveResetMarkerStore, spawn: () -> Bool) -> Bool { + do { + try store.write(LiveReset.Marker(targets: selection.targets)) + } catch { + logger.error("live sessions reset not armed: marker write failed: \(String(describing: error), privacy: .public)") + return false + } + guard spawn() else { + store.remove() + logger.error("live sessions reset not armed: the relauncher did not start") + return false + } + return true + } + /// Keep the exit policy live so a mode selected after launch governs the next launch. static func makeExitCapture(settingsModel: SettingsModel, zmxResolver: ZmxForegroundResolver?) -> ExitCapture { diff --git a/agterm/Control/ControlServer+Zmx.swift b/agterm/Control/ControlServer+Zmx.swift index 93ea60323..5c7b9bf36 100644 --- a/agterm/Control/ControlServer+Zmx.swift +++ b/agterm/Control/ControlServer+Zmx.swift @@ -22,14 +22,54 @@ extension ControlServer { probes[pid] = result return result } - let candidate = zmxClient.flatMap { liveAttributionProbe.hostPID($0.endpoint) } - let host = candidate.flatMap { responsible($0) == .live($0) ? $0 : nil } + let host = liveHostPID(responsible: responsible) return Dictionary(uniqueKeysWithValues: identities.map { identity in let leader = leaders[ZmxSupport.daemonName(for: identity)] return (identity, SessionHost.classify(leader: leader, responsible: leader.map(responsible), hostPid: host, appPid: liveAttributionProbe.appPID)) }) } + /// The session host's pid when its pidfile names a live host, else nil. + private func liveHostPID(responsible: (pid_t) -> SessionHost.ResponsibleProcess) -> pid_t? { + guard let candidate = zmxClient.flatMap({ liveAttributionProbe.hostPID($0.endpoint) }) else { return nil } + return responsible(candidate) == .live(candidate) ? candidate : nil + } + + /// The reset's read-back for the tree top level and the `zmx list` header: nil when nothing is pending + /// and no launch consumed a marker, so an untouched instance shows no field at all. + func liveResetReadback() -> ControlLiveResetReadback? { + let pending = liveReset?.pending.map(\.targets.count) + let last = liveResetOutcome() + guard pending != nil || last != nil else { return nil } + return ControlLiveResetReadback(pending: pending, last: last) + } + + /// `zmx.reset`: the dialog's confirm path without the dialog. The quit is not requested here; the + /// connection thread requests it once this reply is written. + func resetLiveSessions() -> ControlResponse { + guard let liveReset else { + return ControlResponse(ok: false, error: ControlActionsUnsupported.message("zmx.reset")) + } + switch liveReset.request(confirmed: true) { + case .refused(let refusal): + return ControlResponse(ok: false, error: refusal.message) + case .cancelled: + return ControlResponse(ok: false, error: "zmx.reset was cancelled") + case .confirmed(let selection): + let status = ControlLiveResetStatus(sessions: selection.sessionCount, panes: selection.targets.count, pending: true) + return ControlResponse(ok: true, result: ControlResult(text: LiveReset.dialogText(sessionCount: selection.sessionCount).body, + liveReset: status)) + } + } + + /// The panes Help ▸ Reset Live Sessions… would reset: every claim, open or saved, whose daemon leader + /// is orphaned or attributed to this app. Nil when the listing failed, which refuses the action. + func liveResetSelection() -> LiveReset.Selection? { + guard let zmxClient, let records = zmxClient.sessionRecords() else { return nil } + return LiveReset.select(claims: library.paneClaims(), records: records, + classify: liveAttributionProbe.classifier(endpoint: zmxClient.endpoint)) + } + /// Observed daemons joined against the panes that claim them, with the restore status as a header. /// /// A failed listing is an error rather than an empty inventory: an empty namespace is a real answer and @@ -45,7 +85,7 @@ extension ControlServer { let result = ZmxInventory.join(observed: observed, claims: walk.claims, inventoryComplete: walk.complete) let inventory = ControlZmxInventory(restore: restoreStatus(), result: result, - endpoint: client.endpoint) + endpoint: client.endpoint, liveReset: liveResetReadback()) return ControlResponse(ok: true, result: ControlResult(zmx: inventory)) } } @@ -103,7 +143,7 @@ extension ControlServer { result: ZmxInventory.join(observed: observed, claims: walk.claims, inventoryComplete: walk.complete), - endpoint: client.endpoint) + endpoint: client.endpoint, liveReset: liveResetReadback()) // a live store IS the open-window test, the same one `openCounts` uses: a closed window has no // store, and its panes are not attachable from here anyway let windows = library.windows.compactMap { entry in @@ -361,6 +401,21 @@ struct LiveAttributionProbe { var hostPID: (ControlZmxEndpoint) -> pid_t? = LiveAttributionProbe.host var appPID: pid_t = getpid() + /// A classifier over daemon leaders that resolves the host once and probes each pid once. + func classifier(endpoint: ControlZmxEndpoint) -> (String, Int32) -> SessionHost.Attribution { + var probes: [pid_t: SessionHost.ResponsibleProcess] = [:] + func probed(_ pid: pid_t) -> SessionHost.ResponsibleProcess { + if let cached = probes[pid] { return cached } + let result = responsible(pid) + probes[pid] = result + return result + } + let host = hostPID(endpoint).flatMap { probed($0) == .live($0) ? $0 : nil } + return { _, leader in + SessionHost.classify(leader: leader, responsible: probed(leader), hostPid: host, appPid: appPID) + } + } + private static func lookup(_ leader: pid_t) -> SessionHost.ResponsibleProcess { guard Responsibility.system.isAvailable, let pid = Responsibility.system.responsibleProcess(of: leader) else { return .unknown } if kill(pid, 0) == 0 || errno == EPERM { return .live(pid) } diff --git a/agterm/Control/ControlServer.swift b/agterm/Control/ControlServer.swift index 297cf27bb..d328eb915 100644 --- a/agterm/Control/ControlServer.swift +++ b/agterm/Control/ControlServer.swift @@ -147,13 +147,25 @@ final class ControlServer { /// handshake, so this is what covers a remote agterm that never answers. static let remoteTreeDeadline: TimeInterval = 10 + /// Writes one reply frame and reports whether all of it went out. Injectable so a hosted test can hold + /// or fail the `zmx.reset` reply and watch what the quit does. + typealias ResponseWriter = @Sendable (Int32, ControlResponse) -> Bool + nonisolated let responseWriter: ResponseWriter + + /// The Live sessions reset's confirm path; nil refuses `zmx.reset` as unsupported. + var liveReset: LiveResetCoordinator? + /// The last launch's reset outcome for the read-back; injectable so a hosted test stages one. + var liveResetOutcome: () -> LiveReset.Outcome? = { GhosttyApp.shared.liveResetOutcome } + init(library: WindowLibrary, actions: AppActions, settingsModel: SettingsModel, identity: AppIdentity, launchRestoreMode: RestoreMode = GhosttyApp.shared.launchRestoreMode, zmxForegroundResolver: ZmxForegroundResolver? = nil, zmxClient: ZmxClient? = nil, liveAttributionProbe: LiveAttributionProbe = LiveAttributionProbe(), remoteRunner: (any RemoteCommandRunner)? = nil, statusSoundPlayer: StatusSoundPlayer = .shared, - socketPath: String? = nil) { + socketPath: String? = nil, + responseWriter: @escaping ResponseWriter = ControlServer.writeResponse) { + self.responseWriter = responseWriter self.remoteRunner = remoteRunner ?? RemoteCommandProcessRunner() self.library = library self.actions = actions @@ -377,7 +389,7 @@ final class ControlServer { setsockopt(conn, SOL_SOCKET, SO_SNDTIMEO, &writeTimeout, socklen_t(MemoryLayout.size)) guard let line = readLine(conn) else { - writeResponse(conn, ControlResponse(ok: false, error: "request too large or read failed")) + _ = server.responseWriter(conn, ControlResponse(ok: false, error: "request too large or read failed")) return } @@ -389,14 +401,14 @@ final class ControlServer { // the context names the rejected `cmd`, telling a caller its agterm is older than its agtermctl, // and only for a command added after THIS code shipped, since an older server returns the generic. let detail = (error as? DecodingError).map(Self.decodeDetail) ?? error.localizedDescription - writeResponse(conn, ControlResponse(ok: false, error: "invalid request: \(detail)")) + _ = server.responseWriter(conn, ControlResponse(ok: false, error: "invalid request: \(detail)")) return } // answer read-only window queries from the cache without a main-actor hop: a window close briefly // stalls the main thread (surface teardown / re-render), wedging the accept loop against polls. if let cached = server.fastPathResponse(for: request) { - writeResponse(conn, cached) + _ = server.responseWriter(conn, cached) return } @@ -406,7 +418,7 @@ final class ControlServer { handedOff = true let worker = Thread { defer { close(conn) } - writeResponse(conn, runBlocking { await server.dispatch(request) }) + _ = server.responseWriter(conn, runBlocking { await server.dispatch(request) }) } worker.name = "com.umputun.agterm.control.remote" worker.start() @@ -416,7 +428,17 @@ final class ControlServer { // hop to the main actor, blocking this background thread. dispatch refreshes the window cache in that // same execution, so the fast path sees this command's mutations without a second, stallable hop. let response = runBlocking { await server.dispatch(request) } - writeResponse(conn, response) + let written = server.responseWriter(conn, response) + // the quit after a confirmed reset waits for THIS reply to be on the wire, decided from this request + // and this response so a remote worker finishing another reply in parallel can never trigger it + guard request.cmd == .zmxReset, response.ok else { return } + guard written else { + logger.error("zmx.reset reply was not written; the reset stays pending for a later quit") + return + } + DispatchQueue.main.async { + MainActor.assumeIsolated { server.liveReset?.terminateIfPending() } + } } /// Commands whose dispatch awaits an ssh round trip. `zmx.attach` re-resolves the remote first, so it @@ -446,23 +468,24 @@ final class ControlServer { } /// Encode `response` and write it back as a single newline-terminated line. - nonisolated private static func writeResponse(_ conn: Int32, _ response: ControlResponse) { - guard var data = try? JSONEncoder().encode(response) else { return } + nonisolated static func writeResponse(_ conn: Int32, _ response: ControlResponse) -> Bool { + guard var data = try? JSONEncoder().encode(response) else { return false } data.append(UInt8(ascii: "\n")) - data.withUnsafeBytes { raw in + return data.withUnsafeBytes { raw in var offset = 0 let base = raw.bindMemory(to: UInt8.self).baseAddress! let deadline = DispatchTime.now() + .seconds(writeDeadlineSeconds) while offset < data.count { - if DispatchTime.now() > deadline { return } + if DispatchTime.now() > deadline { return false } let n = write(conn, base + offset, data.count - offset) if n < 0 { if errno == EINTR { continue } // retry an interrupted write - return + return false } - if n == 0 { return } + if n == 0 { return false } offset += n } + return true } } @@ -514,7 +537,7 @@ final class ControlServer { .windowNew, .windowList, .windowSelect, .windowClose, .windowRename, .windowDelete, .windowResize, .windowMove, .windowZoom, .windowFullscreen, .windowMinimize, - .restoreClear, .restoreCapture, .restoreMode, .zmxList, .zmxPrune, .zmxKill, .zmxTree, + .restoreClear, .restoreCapture, .restoreMode, .zmxList, .zmxPrune, .zmxKill, .zmxReset, .zmxTree, .zmxAttach, .dashboard, .version: return ControlResponse(ok: false, error: "control dispatcher did not handle \(request.cmd.rawValue)") case .debugAppearance: @@ -788,7 +811,8 @@ final class ControlServer { case .untouched: return "untouched" } }, - app: identity + app: identity, + liveReset: liveResetReadback() ) } diff --git a/agterm/Ghostty/GhosttyApp.swift b/agterm/Ghostty/GhosttyApp.swift index 08848a373..12e90c8df 100644 --- a/agterm/Ghostty/GhosttyApp.swift +++ b/agterm/Ghostty/GhosttyApp.swift @@ -20,6 +20,13 @@ final class GhosttyApp { /// libghostty attributes none to a file. `reloadConfig` surfaces it for the Reload Config / /// `config.reload` warning; the Console log names the offending line. private(set) var lastConfigDiagnosticsCount = 0 + /// What the launch's Live sessions reset did, recorded before any window mounts and posted from the + /// window task once notifications are registered; nil when no marker was consumed. + private(set) var liveResetOutcome: LiveReset.Outcome? + + func recordLiveResetOutcome(_ outcome: LiveReset.Outcome) { + liveResetOutcome = outcome + } /// Terminal background from the resolved config; tints the window so the title bar blends with the /// terminal instead of the default titlebar material. Nil when unread. private(set) var terminalBackgroundColor: NSColor? diff --git a/agterm/Ghostty/LaunchSeed.swift b/agterm/Ghostty/LaunchSeed.swift index d3403da0f..99246ece3 100644 --- a/agterm/Ghostty/LaunchSeed.swift +++ b/agterm/Ghostty/LaunchSeed.swift @@ -29,6 +29,9 @@ struct LaunchSeedPolicy { let restoreEnabled: Bool let denylist: Set let runningNames: Set? + /// Daemons whose reset could not be confirmed at this launch: the pane attaches with neither its + /// captured replay nor its durable command, so a process that may still be running is not started twice. + var suppressedDaemons: Set = [] } extension LaunchSeedProvider { @@ -50,9 +53,10 @@ extension LaunchSeedProvider { private static func seed(session: Session, pane: StatusPane, disposition: ZmxLaunch.Disposition, policy: LaunchSeedPolicy) -> LaunchSeed { switch disposition { - case .wrapped: + case .wrapped(let configuration): guard let seed = ZmxLaunch.surfaceSeed(disposition: disposition, session: session, pane: pane, - denylist: policy.denylist) + denylist: policy.denylist, + suppressed: policy.suppressedDaemons.contains(configuration.daemonName)) else { preconditionFailure("wrapped zmx disposition has no surface seed") } return LaunchSeed(command: seed.command, initialInput: seed.initialInput, waitAfterCommand: false) case .ordinary: @@ -94,6 +98,7 @@ extension LaunchSeedProvider { case .wrapped(let configuration): // an observed daemon is attached to, which runs no program. if policy.runningNames?.contains(configuration.daemonName) == true { return false } + if policy.suppressedDaemons.contains(configuration.daemonName) { return false } // the pending `session.restore` pin is deliberately absent: `surfaceSeed` never reads it. if let capture = peekCapture(session: session, pane: pane) { return CommandRestore.shouldRestore(argv: capture, denylist: policy.denylist) diff --git a/agterm/Ghostty/LiveResetConsumer.swift b/agterm/Ghostty/LiveResetConsumer.swift new file mode 100644 index 000000000..127f95a6b --- /dev/null +++ b/agterm/Ghostty/LiveResetConsumer.swift @@ -0,0 +1,97 @@ +import agtermCore +import Darwin +import Foundation +import os + +/// The launch half of the Live sessions reset: consume the marker before any kill, narrow it against this +/// launch's own claims and listing, end the confirmed daemons in one batch, and wait for their leaders. +/// Survivors are recorded on the spawn context so their panes start neither their replay nor their +/// durable command. +@MainActor +enum LiveResetConsumer { + private static let logger = Logger(subsystem: "com.umputun.agterm", category: "LiveResetConsumer") + + struct Dependencies { + let markerStore: LiveResetMarkerStore + let probe: LiveAttributionProbe + var poll = ZmxClient.LeaderPoll() + var isAlive: (pid_t) -> Bool = { Darwin.kill($0, 0) == 0 || errno == EPERM } + var budget: Duration = .seconds(15) + var listTimeout: TimeInterval = 3 + var killTimeout: TimeInterval = 5 + } + + /// Nil when no marker was armed; an unreadable or undecodable marker is discarded and kills nothing. + static func run(_ deps: Dependencies, library: WindowLibrary, client: ZmxClient, + context: agtermApp.LaunchSpawnContext) -> LiveReset.Outcome? { + let marker: LiveReset.Marker + do { + guard let consumed = try deps.markerStore.consume() else { return nil } + marker = consumed + } catch { + logger.error("live sessions reset marker discarded: \(String(describing: error), privacy: .public)") + return nil + } + let deadline = deps.poll.now().advanced(by: deps.budget) + func remaining() -> TimeInterval { + let left = deps.poll.now().duration(to: deadline) + return max(0, Double(left.components.seconds) + Double(left.components.attoseconds) / 1e18) + } + let claims = library.paneClaims() + let claimed: Set? = claims.complete ? Set(claims.claims.map(\.paneIdentity)) : nil + let records = client.sessionRecords(timeout: min(deps.listTimeout, max(remaining(), 0.1))) + let narrowed = LiveReset.narrow(marker: marker, claimed: claimed, records: records, + classify: deps.probe.classifier(endpoint: client.endpoint)) + let kill = narrowed.kill + var survivors: Set = [] + if !kill.isEmpty { + // a batch that could not start before the budget ran out is treated like one whose result is + // unknown: every selected leader stays suppressed rather than restored + let timeout = min(deps.killTimeout, remaining()) + if timeout <= 0 { + logger.error("live sessions reset: the budget expired before the kill; leaving every selected pane suppressed") + survivors = Set(kill.map(\.leaderPID)) + } else { + if !client.killBatch(names: kill.map(\.daemon), timeout: timeout) { + logger.error("live sessions reset: the batched kill did not complete; polling every leader anyway") + } + survivors = ZmxClient.leadersExited(Set(kill.map(\.leaderPID)), deadline: deadline, poll: deps.poll, isAlive: deps.isAlive) + } + } + let outcome = LiveReset.outcome(narrowed: narrowed, survivors: survivors, inventoryFailed: narrowed.inventoryFailed) + context.suppressedLaunchPayloads = Set(outcome.unconfirmed) + deps.markerStore.removeConsumed() + logger.info("live sessions reset: \(outcome.panes.killed) killed, \(outcome.panes.gone) gone, \(outcome.panes.skipped) skipped, \(outcome.unconfirmed.count) unconfirmed") + return outcome + } +} + +/// The launch steps that must follow one another once the library exists: the reset consumer, then the +/// ordinary reap over the inventory the library collected, then the foreground resolver's refresh. +@MainActor +enum LaunchOrchestration { + struct Inputs { + let library: WindowLibrary + let client: ZmxClient + let resolver: ZmxForegroundResolver + let context: agtermApp.LaunchSpawnContext + let launchDecision: RestoreLaunchDecision + } + + /// A launch that did not get Live cannot suppress a survivor's ordinary seed, so a marker found by it + /// is discarded rather than consumed: nothing is killed and the panes restore as that mode restores them. + static func run(_ inputs: Inputs, consumer: LiveResetConsumer.Dependencies?) -> LiveReset.Outcome? { + let live = inputs.launchDecision.requested == .live && inputs.launchDecision.active == .live + let outcome: LiveReset.Outcome? = consumer.flatMap { + guard live else { + $0.markerStore.remove() + return nil + } + return LiveResetConsumer.run($0, library: inputs.library, client: inputs.client, context: inputs.context) + } + inputs.context.runningNames = inputs.client.reap(knownPaneIdentities: inputs.context.launchInventory, + launchDecision: inputs.launchDecision).runningNames + inputs.resolver.noteLifecycleChange() + return outcome + } +} diff --git a/agterm/Ghostty/ZmxClient.swift b/agterm/Ghostty/ZmxClient.swift index b967dc4df..e12fc7b95 100644 --- a/agterm/Ghostty/ZmxClient.swift +++ b/agterm/Ghostty/ZmxClient.swift @@ -102,14 +102,44 @@ final class ZmxClient { } func sessionLeaderPIDs(timeout: TimeInterval? = nil) -> [String: pid_t]? { + sessionRecords(timeout: timeout).map(ZmxLeaderMap.leaders(in:)) + } + + /// The parsed listing, unreadable rows included, so a caller can tell an absent daemon from one zmx + /// could not read. Nil when the listing itself failed. + func sessionRecords(timeout: TimeInterval? = nil) -> [ZmxSessionRecord]? { do { - return ZmxLeaderMap.leaders(in: try ZmxListParser.parse(invoke(["list"], timeout: timeout))) + return try ZmxListParser.parse(invoke(["list"], timeout: timeout)) } catch { - Self.logger.error("zmx leader refresh failed: \(String(describing: error), privacy: .public)") + Self.logger.error("zmx list failed: \(String(describing: error), privacy: .public)") return nil } } + /// One `zmx kill … --force` for every name. The result is diagnostic only: zmx handles the names in + /// order, so a failure part-way has already reached some daemons, and the caller confirms each by + /// leader exit rather than by this Bool. + func killBatch(names: [String], timeout: TimeInterval) -> Bool { + kill(names: names, timeout: timeout) + } + + struct LeaderPoll { + var now: () -> ContinuousClock.Instant = { .now } + var sleep: (Duration) -> Void = { Thread.sleep(forTimeInterval: Double($0.components.seconds) + Double($0.components.attoseconds) / 1e18) } + var every: Duration = .milliseconds(100) + } + + /// Polls the group until every leader is gone or the deadline passes; returns the pids still alive. + nonisolated static func leadersExited(_ pids: Set, deadline: ContinuousClock.Instant, poll: LeaderPoll, + isAlive: (pid_t) -> Bool) -> Set { + var pending = pids + while true { + pending = pending.filter(isAlive) + if pending.isEmpty || poll.now() >= deadline { return pending } + poll.sleep(poll.every) + } + } + /// What a single unforced kill actually did. `staleSocket` is its own case because zmx prints /// `cleaned up stale session` and exits ZERO after merely unlinking a socket it could not connect to — /// the daemon may still be running, unreachable by name, so counting that as a kill would report a @@ -166,12 +196,12 @@ final class ZmxClient { } } - private func kill(names: [String]) -> Bool { + private func kill(names: [String], timeout: TimeInterval? = nil) -> Bool { var seen: Set = [] let unique = names.filter { seen.insert($0).inserted } guard !unique.isEmpty else { return true } do { - _ = try invoke(["kill"] + unique + ["--force"]) + _ = try invoke(["kill"] + unique + ["--force"], timeout: timeout) return true } catch { Self.logger.error("zmx kill failed for \(unique.joined(separator: ","), privacy: .public): \(String(describing: error), privacy: .public)") diff --git a/agterm/Ghostty/ZmxLaunch.swift b/agterm/Ghostty/ZmxLaunch.swift index 2914fa8a4..ee80f0ab9 100644 --- a/agterm/Ghostty/ZmxLaunch.swift +++ b/agterm/Ghostty/ZmxLaunch.swift @@ -97,12 +97,15 @@ enum ZmxLaunch { ZmxSupport.launchDisposition(requested: requested, active: active, configuration: configuration) } + /// `suppressed` consumes the pending replay without using it and withholds the durable command: the + /// pane's old process may still be running, and the attach must not start a second copy. @MainActor static func surfaceSeed(disposition: Disposition, session: Session, pane: StatusPane, - denylist: Set) -> SurfaceSeed? { + denylist: Set, suppressed: Bool = false) -> SurfaceSeed? { guard case .wrapped(let configuration) = disposition else { return nil } - let replay = session.takePendingForegroundCommand(pane: pane) - let creationCommand: String? = if replay == nil { + let captured = session.takePendingForegroundCommand(pane: pane) + let replay = suppressed ? nil : captured + let creationCommand: String? = if replay == nil, !suppressed { switch pane { case .left: session.initialCommand case .right: session.splitInitialCommand diff --git a/agterm/LiveResetCoordinator.swift b/agterm/LiveResetCoordinator.swift new file mode 100644 index 000000000..1e45037a2 --- /dev/null +++ b/agterm/LiveResetCoordinator.swift @@ -0,0 +1,117 @@ +import AppKit +import agtermCore + +/// The one confirm path for Help ▸ Reset Live Sessions… and `zmx.reset`: refuses in a fixed order, shows +/// the dialog unless already confirmed, and holds the selection for the quit. The caller decides when to +/// terminate, the menu right away and the control server after its reply is written; `AppDelegate` reads +/// `armablePending` to skip the quit alert and to arm the marker. +@MainActor +final class LiveResetCoordinator { + enum Refusal: Equatable { + case notLive + case listingFailed + case inventoryIncomplete + case nothingToReset + + var message: String { + switch self { + case .notLive: "zmx.reset requires Live sessions mode both configured and active for this launch" + case .listingFailed: "zmx.reset could not read the live session list" + case .inventoryIncomplete: "zmx.reset refused: the pane inventory is incomplete" + case .nothingToReset: "zmx.reset found no live session to reset" + } + } + + var userMessage: String { + switch self { + case .notLive: "Reset Live Sessions needs Live sessions mode for this launch and the next." + case .listingFailed: "The live session list could not be read. Nothing was reset." + case .inventoryIncomplete: "The saved sessions could not be verified. Nothing was reset." + case .nothingToReset: "No live sessions could be selected for reset." + } + } + } + + enum Request: Equatable { + case refused(Refusal) + case cancelled + case confirmed(LiveReset.Selection) + } + + private let settingsModel: SettingsModel + /// The control server's join of claims and daemons; nil refuses as a failed listing. + var selection: () -> LiveReset.Selection? + /// The mode this process launched with; injectable so a hosted test can stage the Live gate. + var activeMode: () -> RestoreMode + /// How a confirmed reset ends the process; injectable so a hosted test can count it instead. + var terminate: () -> Void + /// The dialog, given the session count; injectable so a hosted test can answer it. + var confirm: @MainActor (Int) -> Bool = LiveResetCoordinator.confirmAlert + /// How a menu refusal reaches the user; injectable so a hosted test can read it. + var presentRefusal: @MainActor (Refusal) -> Void = LiveResetCoordinator.refusalAlert + private(set) var pending: LiveReset.Selection? + + init(settingsModel: SettingsModel, selection: @escaping () -> LiveReset.Selection?, + activeMode: @escaping () -> RestoreMode = { GhosttyApp.shared.launchRestoreMode }, + terminate: @escaping () -> Void = { NSApp.terminate(nil) }) { + self.settingsModel = settingsModel + self.selection = selection + self.activeMode = activeMode + self.terminate = terminate + } + + var menuVisible: Bool { + LiveReset.menuVisible(configured: settingsModel.settings.effectiveRestoreMode, active: activeMode()) + } + + /// The confirmed selection while Live is still both the configured and the launched mode. A mode + /// change after confirmation makes the next launch unable to suppress survivors, so the reset is + /// neither armed nor allowed to skip the quit alert. + var armablePending: LiveReset.Selection? { + menuVisible ? pending : nil + } + + func request(confirmed: Bool) -> Request { + guard menuVisible else { return .refused(.notLive) } + guard let selection = selection() else { return .refused(.listingFailed) } + guard selection.inventoryComplete else { return .refused(.inventoryIncomplete) } + guard !selection.targets.isEmpty else { return .refused(.nothingToReset) } + if !confirmed, !confirm(selection.sessionCount) { return .cancelled } + pending = selection + return .confirmed(selection) + } + + /// The Help item: a refusal is shown, a cancel is silent, a confirmation quits. + func runFromMenu() { + switch request(confirmed: false) { + case .refused(let refusal): presentRefusal(refusal) + case .cancelled: break + case .confirmed: terminateIfPending() + } + } + + func terminateIfPending() { + guard armablePending != nil else { return } + terminate() + } + + private static func confirmAlert(sessionCount: Int) -> Bool { + let text = LiveReset.dialogText(sessionCount: sessionCount) + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = text.title + alert.informativeText = text.body + alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: "Reset") + return alert.runModal() == .alertSecondButtonReturn + } + + private static func refusalAlert(_ refusal: Refusal) { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = "Reset Live Sessions" + alert.informativeText = refusal.userMessage + alert.addButton(withTitle: "OK") + alert.runModal() + } +} diff --git a/agterm/LiveResetRelauncher.swift b/agterm/LiveResetRelauncher.swift new file mode 100644 index 000000000..0b868ad16 --- /dev/null +++ b/agterm/LiveResetRelauncher.swift @@ -0,0 +1,42 @@ +import Foundation +import os + +/// Reopens agterm after a Live sessions reset quit. A detached shell waits for this pid to exit, then runs +/// the standard app launch; `NSWorkspace` cannot do that from the exiting process, and opening before exit +/// reaches the running instance. Arguments travel positionally, never inside the script text. +struct LiveResetRelauncher { + private static let logger = Logger(subsystem: "com.umputun.agterm", category: "LiveResetRelauncher") + + static let script = """ + i=0 + while kill -0 "$1" 2>/dev/null; do + i=$((i + 1)) + [ "$i" -ge "$5" ] && exit 1 + sleep 0.2 + done + if [ -n "$3" ]; then exec "$4" -n "$2" --env "AGTERM_STATE_DIR=$3"; fi + exec "$4" -n "$2" + """ + + var shell = "/bin/sh" + var open = "/usr/bin/open" + /// Polls of 0.2 s before the waiter gives up without launching. + var maxWaits = 300 + + func spawn(pid: pid_t, bundle: URL, stateDirectory: String?) -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: shell) + process.arguments = ["-c", Self.script, "agterm-live-reset", String(pid), bundle.path, stateDirectory ?? "", + open, String(maxWaits)] + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + return true + } catch { + Self.logger.error("could not start the relauncher: \(String(describing: error), privacy: .public)") + return false + } + } +} diff --git a/agterm/Notifications/NotificationManager.swift b/agterm/Notifications/NotificationManager.swift index d52e8a7df..330f5bb20 100644 --- a/agterm/Notifications/NotificationManager.swift +++ b/agterm/Notifications/NotificationManager.swift @@ -162,6 +162,19 @@ final class NotificationManager: NSObject, @preconcurrency UNUserNotificationCen } } + /// Post a banner when the launch's Live sessions reset left sessions behind. App-level like the + /// diagnostics banners; silent when every session was reset. + func notifyLiveResetOutcome(_ outcome: LiveReset.Outcome) { + guard bannersEnabled, let body = LiveReset.notificationText(outcome: outcome) else { return } + let content = UNMutableNotificationContent() + content.title = "Live Sessions" + content.body = body + let request = UNNotificationRequest(identifier: "live-reset", content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) { error in + if let error { logger.error("live-reset banner add failed: \(error.localizedDescription, privacy: .public)") } + } + } + /// Post a banner when the ghostty config reloaded with problems (parse errors or invalid keys), visible /// without digging through the log. The count spans ALL config sources (bundled defaults, global /// `~/.config/ghostty/config`, agterm-scoped `ghostty.conf`, the UI settings conf) — libghostty diff --git a/agterm/agtermApp+Menus.swift b/agterm/agtermApp+Menus.swift index 086d652ac..c75dbb59a 100644 --- a/agterm/agtermApp+Menus.swift +++ b/agterm/agtermApp+Menus.swift @@ -407,6 +407,10 @@ extension agtermApp { Button("Install Command Line Tool…") { CLIInstaller.run() } Button("Install Agent Status Hooks…") { AgentHooksInstaller.run() } Button("Install Agent Skill…") { SkillInstaller.run() } + if liveReset.menuVisible { + Divider() + Button("Reset Live Sessions…") { liveReset.runFromMenu() } + } } } diff --git a/agterm/agtermApp.swift b/agterm/agtermApp.swift index e5d146e5b..820f30d2e 100644 --- a/agterm/agtermApp.swift +++ b/agterm/agtermApp.swift @@ -21,6 +21,7 @@ struct agtermApp: App { @State private var globalHotkey: GlobalHotkey @State var settingsModel: SettingsModel @State private var controlServer: ControlServer + @State var liveReset: LiveResetCoordinator @State private var customCommandRunner: CustomCommandRunner @State private var appearanceObserver: SystemAppearanceObserver @State private var accessibilityObserver: SystemAccessibilityObserver @@ -38,6 +39,9 @@ struct agtermApp: App { /// what arms it. private let spawnRegistry: SpawnRegistry private let launchContext: LaunchSpawnContext + /// The one-shot marker for a confirmed Live sessions reset, in the state directory; handed to the + /// delegate on scene appear because the quit path is the only writer. + private let liveResetMarkerStore: LiveResetMarkerStore /// The plain `WindowGroup`'s scene id, used by `openWindow(id:)` to spawn additional windows. private static let windowGroupID = "terminal" @@ -64,6 +68,7 @@ struct agtermApp: App { init() { let stateDirectory = ProcessInfo.processInfo.environment["AGTERM_STATE_DIR"] .map { URL(fileURLWithPath: $0, isDirectory: true) } ?? PersistenceStore.defaultDirectory + liveResetMarkerStore = LiveResetMarkerStore(directory: stateDirectory) // FIRST, before anything reads or writes the state directory: `WindowLibrary`'s bootstrap seeds a // window and saves it, which a later read would see as evidence of an earlier launch. let hadPriorState = FirstRunWelcome.hasPriorState(in: stateDirectory) @@ -92,6 +97,10 @@ struct agtermApp: App { zmxForegroundResolver: restored.foregroundResolver, zmxClient: restored.zmxClient) _controlServer = State(initialValue: controlServer) + let liveReset = LiveResetCoordinator(settingsModel: settingsModel, + selection: { [weak controlServer] in controlServer?.liveResetSelection() }) + controlServer.liveReset = liveReset + _liveReset = State(initialValue: liveReset) _sessionSwitcher = State(initialValue: SessionSwitcher(library: library, canSwitch: { actions.uiActionsEnabled })) _paneShortcuts = State(initialValue: PaneShortcuts(library: library, actions: actions)) _undoCloseShortcut = State(initialValue: UndoCloseShortcut(actions: actions)) @@ -190,6 +199,8 @@ struct agtermApp: App { // `.agtermKeymapChanged`, removed on terminate via the delegate reference. appDelegate.customCommandRunner = customCommandRunner appDelegate.settingsModel = settingsModel + appDelegate.liveResetMarkerStore = liveResetMarkerStore + appDelegate.liveReset = liveReset // hand the delegate the action hub and drain folders `open -a agterm /path` queued // before the window store resolved. appDelegate.actions = actions @@ -223,6 +234,10 @@ struct agtermApp: App { if !library.hasReopened, GhosttyApp.shared.lastConfigDiagnosticsCount > 0 { NotificationManager.shared.notifyConfigDiagnostics(count: GhosttyApp.shared.lastConfigDiagnosticsCount) } + // same for the Live sessions reset, recorded by `restoredRuntime` before any window + if !library.hasReopened, let outcome = GhosttyApp.shared.liveResetOutcome { + NotificationManager.shared.notifyLiveResetOutcome(outcome) + } // runs once via the library latch — the .task fires per window. reopenWindows() appDelegate.scheduleRestoredWindowReconciliation(reason: "scene-task") @@ -265,6 +280,10 @@ struct agtermApp: App { @MainActor final class LaunchSpawnContext { var runningNames: Set? + /// The claimed pane identities the library inventoried during bootstrap; nil when incomplete. + var launchInventory: Set? + /// Panes whose reset could not be confirmed: they attach with no replay and no durable command. + var suppressedLaunchPayloads: Set = [] } /// Builds the window library and zmx foreground resolver for the state directory. Bootstrap @@ -288,14 +307,19 @@ struct agtermApp: App { _ = client.kill(paneIdentities: $0) foregroundResolver.noteLifecycleChange() }, - launchInventorySink: { - context.runningNames = client.reap(knownPaneIdentities: $0, - launchDecision: ghostty.restoreLaunchDecision).runningNames - foregroundResolver.noteLifecycleChange() - }, + launchInventorySink: { context.launchInventory = $0 }, launchPaneDrop: { identities in for identity in identities { pacer.discard(identity) } }) + // the reap waits for the library so a confirmed Live sessions reset can narrow its marker against the + // current claims first; both finish before any window mounts + let consumer = LiveResetConsumer.Dependencies(markerStore: LiveResetMarkerStore(directory: stateDirectory), + probe: LiveAttributionProbe()) + let launch = LaunchOrchestration.Inputs(library: library, client: client, resolver: foregroundResolver, + context: context, launchDecision: ghostty.restoreLaunchDecision) + if let outcome = LaunchOrchestration.run(launch, consumer: consumer) { + ghostty.recordLiveResetOutcome(outcome) + } return RestoredRuntime(library: library, foregroundResolver: foregroundResolver, zmxClient: client, spawnContext: context) } @@ -428,7 +452,8 @@ struct agtermApp: App { @MainActor static func launchSeedPolicy(_ ghostty: GhosttyApp, context: LaunchSpawnContext) -> LaunchSeedPolicy { LaunchSeedPolicy(restoreEnabled: ghostty.restoreRunningCommand, denylist: ghostty.restoreDenylist, - runningNames: context.runningNames) + runningNames: context.runningNames, + suppressedDaemons: Set(context.suppressedLaunchPayloads.map(ZmxSupport.daemonName(for:)))) } /// A wrapped pane's shell environment is zmx's own; every other disposition inherits the pane env. diff --git a/agtermCore/Sources/agtermCore/AppStore.swift b/agtermCore/Sources/agtermCore/AppStore.swift index a318683b3..196155410 100644 --- a/agtermCore/Sources/agtermCore/AppStore.swift +++ b/agtermCore/Sources/agtermCore/AppStore.swift @@ -274,7 +274,8 @@ public final class AppStore { dashboardMembers: () -> [String]? = { nil }, dashboardHighlighted: () -> String? = { nil }, dashboardFontSize: () -> Double? = { nil }, - dashboardFontMode: () -> String? = { nil }, app: AppIdentity? = nil) -> ControlTree { + dashboardFontMode: () -> String? = { nil }, app: AppIdentity? = nil, + liveReset: ControlLiveResetReadback? = nil) -> ControlTree { let activeID = selectedSessionID // `currentWorkspaceID`, not the selected session's owner: an EMPTY destination selects nothing, so // deriving this from the selection alone made `tree` name the workspace `workspace.go` just left. @@ -355,7 +356,7 @@ public final class AppStore { dashboardHighlighted: dashboardHighlighted(), dashboardFontSize: dashboardFontSize(), dashboardFontMode: dashboardFontMode(), - pickPending: pickPending(), askPending: askPending(), app: app) + pickPending: pickPending(), askPending: askPending(), app: app, liveReset: liveReset) } /// The tree's `paneOverlays`: the panes covered by their own overlay, omitted when neither is. diff --git a/agtermCore/Sources/agtermCore/ControlActionsDefaults.swift b/agtermCore/Sources/agtermCore/ControlActionsDefaults.swift index a756e4ab4..44d5fbda2 100644 --- a/agtermCore/Sources/agtermCore/ControlActionsDefaults.swift +++ b/agtermCore/Sources/agtermCore/ControlActionsDefaults.swift @@ -38,6 +38,10 @@ public extension ControlActions { ControlResponse(ok: false, error: ControlActionsUnsupported.message("zmx.kill")) } + func resetLiveSessions() -> ControlResponse { + ControlResponse(ok: false, error: ControlActionsUnsupported.message("zmx.reset")) + } + func remoteTree(host _: String?) async -> ControlResponse { ControlResponse(ok: false, error: ControlActionsUnsupported.message("zmx.tree")) } diff --git a/agtermCore/Sources/agtermCore/ControlDispatcher+Zmx.swift b/agtermCore/Sources/agtermCore/ControlDispatcher+Zmx.swift index ff586c427..4566f79c2 100644 --- a/agtermCore/Sources/agtermCore/ControlDispatcher+Zmx.swift +++ b/agtermCore/Sources/agtermCore/ControlDispatcher+Zmx.swift @@ -56,6 +56,11 @@ extension ControlDispatcher { return ControlResponse(ok: false, error: "zmx.kill requires --force") } return actions.killZmxDaemon(target: target, window: request.args?.window, pane: pane) + case .zmxReset: + guard request.args?.force == true else { + return ControlResponse(ok: false, error: "zmx.reset requires --force") + } + return actions.resetLiveSessions() default: preconditionFailure("unexpected zmx command: \(request.cmd.rawValue)") diff --git a/agtermCore/Sources/agtermCore/ControlDispatcher.swift b/agtermCore/Sources/agtermCore/ControlDispatcher.swift index f1c8ed483..6a3fd88b5 100644 --- a/agtermCore/Sources/agtermCore/ControlDispatcher.swift +++ b/agtermCore/Sources/agtermCore/ControlDispatcher.swift @@ -153,6 +153,9 @@ public protocol ControlActions { /// Destroy ONE pane's daemon. The host resolves the owner against the inventory rather than the open /// stores, since this reaches closed and unindexed claims the target resolver cannot see. func killZmxDaemon(target: String, window: String?, pane: ZmxPaneRole) -> ControlResponse + /// Confirm the Live sessions reset without the dialog: the host selects the panes, refuses outside Live + /// or on an incomplete inventory, answers, and quits only after this reply is written. + func resetLiveSessions() -> ControlResponse /// Another machine's attachable sessions. Async because it runs ssh: a blocking wait here would hold /// the main actor for the whole network deadline. func remoteTree(host: String?) async -> ControlResponse @@ -197,7 +200,7 @@ public struct ControlDispatcher { .configReload, .notify, .themeSet, .themeList, .sidebar, .sidebarMode, .sidebarExpand, .sidebarCollapse, .sidebarWidth, .restoreClear, .restoreCapture, .version: return dispatchAppCommand(request) - case .restoreMode, .zmxList, .zmxPrune, .zmxKill, .zmxTree, .zmxAttach: + case .restoreMode, .zmxList, .zmxPrune, .zmxKill, .zmxReset, .zmxTree, .zmxAttach: return await dispatchZmxCommand(request) case .quickType, .quickText: return await dispatchQuickCommand(request) diff --git a/agtermCore/Sources/agtermCore/ControlPayloads.swift b/agtermCore/Sources/agtermCore/ControlPayloads.swift index 7741d3243..a88f8bbb1 100644 --- a/agtermCore/Sources/agtermCore/ControlPayloads.swift +++ b/agtermCore/Sources/agtermCore/ControlPayloads.swift @@ -169,17 +169,47 @@ public struct ControlZmxInventory: Codable, Sendable, Equatable { /// Header rather than per row: one instance has one zmx and one socket directory. Optional so a /// remote reader can tell an older server apart from one that reports nothing to attach to. public let endpoint: ControlZmxEndpoint? + /// The Live sessions reset state, repeated from the tree top level; omitted when nothing is pending + /// and no launch has consumed a marker. + public let liveReset: ControlLiveResetReadback? public let entries: [ControlZmxEntry] public init(restore: ControlRestoreStatus, result: ZmxInventoryResult, - endpoint: ControlZmxEndpoint? = nil) { + endpoint: ControlZmxEndpoint? = nil, liveReset: ControlLiveResetReadback? = nil) { self.restore = restore inventoryComplete = result.inventoryComplete self.endpoint = endpoint + self.liveReset = liveReset entries = result.rows.map(ControlZmxEntry.init(row:)) } } +/// `zmx.reset`'s acknowledgement: what was confirmed for the next launch. `pending` is true once the +/// app holds the set and is about to quit. +public struct ControlLiveResetStatus: Codable, Sendable, Equatable { + public let sessions: Int + public let panes: Int + public let pending: Bool + + public init(sessions: Int, panes: Int, pending: Bool) { + self.sessions = sessions + self.panes = panes + self.pending = pending + } +} + +/// The reset's read-back on the tree top level and the `zmx list` header. `pending` is the confirmed pane +/// count held in memory until the quit; `last` is the outcome of the launch that consumed a marker. +public struct ControlLiveResetReadback: Codable, Sendable, Equatable { + public let pending: Int? + public let last: LiveReset.Outcome? + + public init(pending: Int?, last: LiveReset.Outcome?) { + self.pending = pending + self.last = last + } +} + /// `surface.cursor`'s payload, nested so a `row` could join it additively rather than by a rename. /// /// There is no row: `tl_px_y` is the text BASELINE against an IME point at the cell bottom, leaving a term diff --git a/agtermCore/Sources/agtermCore/ControlProjection.swift b/agtermCore/Sources/agtermCore/ControlProjection.swift index ec14b13d6..3fc329a97 100644 --- a/agtermCore/Sources/agtermCore/ControlProjection.swift +++ b/agtermCore/Sources/agtermCore/ControlProjection.swift @@ -413,6 +413,9 @@ public struct ControlTree: Codable, Sendable, Equatable { /// agent already reading the tree gets its version floor without a second round-trip; `version` answers /// the same question for a caller that has no tree, no window, and no JSON parser. public let app: AppIdentity? + /// The Live sessions reset state: app-global like `app`, omitted when nothing is pending and no launch + /// has consumed a marker. The read side of `zmx.reset`. + public let liveReset: ControlLiveResetReadback? public init(workspaces: [ControlWorkspaceNode], idleMs: Int? = nil, autoFollowMs: Int? = nil, sidebarVisible: Bool? = nil, sidebarMode: String? = nil, sidebarWidth: Double? = nil, workspaceFilter: Bool? = nil, @@ -420,8 +423,9 @@ public struct ControlTree: Codable, Sendable, Equatable { zoomedSurface: String? = nil, dashboardMembers: [String]? = nil, dashboardHighlighted: String? = nil, dashboardFontSize: Double? = nil, dashboardFontMode: String? = nil, pickPending: String? = nil, askPending: String? = nil, - app: AppIdentity? = nil) { + app: AppIdentity? = nil, liveReset: ControlLiveResetReadback? = nil) { self.workspaces = workspaces + self.liveReset = liveReset self.idleMs = idleMs self.autoFollowMs = autoFollowMs self.sidebarVisible = sidebarVisible diff --git a/agtermCore/Sources/agtermCore/ControlProtocol.swift b/agtermCore/Sources/agtermCore/ControlProtocol.swift index 52ec56a62..a45fff634 100644 --- a/agtermCore/Sources/agtermCore/ControlProtocol.swift +++ b/agtermCore/Sources/agtermCore/ControlProtocol.swift @@ -92,6 +92,7 @@ public enum Command: String, Codable, Sendable { case zmxList = "zmx.list" case zmxPrune = "zmx.prune" case zmxKill = "zmx.kill" + case zmxReset = "zmx.reset" case zmxTree = "zmx.tree" case zmxAttach = "zmx.attach" /// UI-TEST-ONLY: forces the app-level appearance (`light`|`dark` via `args.name`) so an XCUITest can @@ -521,6 +522,8 @@ public struct ControlResult: Codable, Sendable, Equatable { public var zmx: ControlZmxInventory? /// Another machine's attachable sessions, for `zmx tree`. public var remote: ControlRemoteTree? + /// What `zmx.reset` confirmed: the sessions and panes it will reset at the next launch. + public var liveReset: ControlLiveResetStatus? public init(id: String? = nil, tree: ControlTree? = nil, text: String? = nil, windows: [ControlWindowNode]? = nil, exitCode: Int? = nil, count: Int? = nil, @@ -532,12 +535,14 @@ public struct ControlResult: Codable, Sendable, Equatable { pick: ControlPickResult? = nil, ask: ControlAskResult? = nil, cursor: ControlCursor? = nil, app: AppIdentity? = nil, restore: ControlRestoreStatus? = nil, zmx: ControlZmxInventory? = nil, remote: ControlRemoteTree? = nil, + liveReset: ControlLiveResetStatus? = nil, width: Int? = nil, height: Int? = nil) { self.width = width self.height = height self.restore = restore self.zmx = zmx self.remote = remote + self.liveReset = liveReset self.id = id self.tree = tree self.text = text diff --git a/agtermCore/Sources/agtermCore/LiveReset.swift b/agtermCore/Sources/agtermCore/LiveReset.swift new file mode 100644 index 000000000..e5f469a2a --- /dev/null +++ b/agtermCore/Sources/agtermCore/LiveReset.swift @@ -0,0 +1,261 @@ +import Foundation + +/// Selection, next-launch narrowing and reporting for Help ▸ Reset Live Sessions… and `zmx.reset`. +/// Host-free: the app joins claims to daemons and kills; this decides which and reports what happened. +public enum LiveReset { + /// One pane confirmed for reset, with the leader observed at confirmation so the next launch can + /// tell the same daemon from a replacement. + public struct Target: Codable, Hashable, Sendable { + public let paneIdentity: UUID + public let sessionID: UUID + public let daemon: String + public let leaderPID: Int32 + + public init(paneIdentity: UUID, sessionID: UUID, daemon: String, leaderPID: Int32) { + self.paneIdentity = paneIdentity + self.sessionID = sessionID + self.daemon = daemon + self.leaderPID = leaderPID + } + } + + /// The confirmed set written at quit and consumed once at the next launch. + public struct Marker: Codable, Equatable, Sendable { + public static let currentVersion = 1 + public let version: Int + public let createdAt: Date + public let targets: [Target] + + public init(targets: [Target], createdAt: Date = Date()) { + self.version = Self.currentVersion + self.createdAt = createdAt + self.targets = targets + } + } + + /// What the dialog offers: the panes whose process is not supervised, and whether the walk that + /// found them was complete. An incomplete walk, or one pane claimed twice, forbids the action. + public struct Selection: Equatable, Sendable { + public let targets: [Target] + public let inventoryComplete: Bool + + public init(targets: [Target], inventoryComplete: Bool) { + self.targets = targets + self.inventoryComplete = inventoryComplete + } + + public var sessionCount: Int { Set(targets.map(\.sessionID)).count } + } + + public static func select(claims: ZmxClaimWalk, records: [ZmxSessionRecord], + classify: (String, Int32) -> SessionHost.Attribution) -> Selection { + let leaders = ZmxLeaderMap.leaders(in: records) + var seen: Set = [] + var conflicted = false + let targets = claims.claims.compactMap { claim -> Target? in + guard seen.insert(claim.paneIdentity).inserted else { conflicted = true; return nil } + let name = ZmxSupport.daemonName(for: claim.paneIdentity) + guard let leader = leaders[name] else { return nil } + switch classify(name, leader) { + case .orphaned, .app: + return Target(paneIdentity: claim.paneIdentity, sessionID: claim.sessionID, daemon: name, leaderPID: leader) + case .supervisor, .unknown: + return nil + } + } + return Selection(targets: targets, inventoryComplete: claims.complete && !conflicted) + } + + public enum Disposition: String, Codable, Equatable, Sendable { + case kill, gone, skipped + } + + /// The marker re-checked against the launch's own claims and listing. Only narrows: a target is + /// killed when it is still claimed, still listed with the same leader, and still orphaned. + public struct Narrowed: Equatable, Sendable { + public let dispositions: [Target: Disposition] + public let inventoryFailed: Bool + + public init(dispositions: [Target: Disposition], inventoryFailed: Bool) { + self.dispositions = dispositions + self.inventoryFailed = inventoryFailed + } + + public var kill: [Target] { + dispositions.filter { $0.value == .kill }.map(\.key).sorted { $0.daemon < $1.daemon } + } + } + + public static func narrow(marker: Marker, claimed: Set?, records: [ZmxSessionRecord]?, + classify: (String, Int32) -> SessionHost.Attribution) -> Narrowed { + guard let records, let claimed else { + let skipped = Dictionary(marker.targets.map { ($0, Disposition.skipped) }, uniquingKeysWith: { first, _ in first }) + return Narrowed(dispositions: skipped, inventoryFailed: records == nil) + } + let byName = Dictionary(records.map { ($0.name, $0) }, uniquingKeysWith: { first, _ in first }) + var dispositions: [Target: Disposition] = [:] + for target in marker.targets { + guard claimed.contains(target.paneIdentity) else { dispositions[target] = .skipped; continue } + guard let record = byName[target.daemon] else { dispositions[target] = .gone; continue } + guard record.leaderPID == target.leaderPID, classify(target.daemon, target.leaderPID) == .orphaned else { + dispositions[target] = .skipped + continue + } + dispositions[target] = .kill + } + return Narrowed(dispositions: dispositions, inventoryFailed: false) + } + + public struct PaneCounts: Codable, Equatable, Sendable { + public let confirmed: Int + public let killed: Int + public let gone: Int + public let skipped: Int + + public init(confirmed: Int, killed: Int, gone: Int, skipped: Int) { + self.confirmed = confirmed + self.killed = killed + self.gone = gone + self.skipped = skipped + } + } + + /// Distinct sessions among the targets. A session is reset only when every one of its panes was + /// killed and confirmed gone or had no daemon; any other pane makes it partial. + public struct SessionCounts: Codable, Equatable, Sendable { + public let affected: Int + public let reset: Int + public let partial: Int + public let unconfirmed: Int + + public init(affected: Int, reset: Int, partial: Int, unconfirmed: Int) { + self.affected = affected + self.reset = reset + self.partial = partial + self.unconfirmed = unconfirmed + } + } + + public struct Outcome: Codable, Equatable, Sendable { + public let panes: PaneCounts + public let unconfirmed: [UUID] + public let sessions: SessionCounts + public let inventoryFailed: Bool + + public init(panes: PaneCounts, unconfirmed: [UUID], sessions: SessionCounts, inventoryFailed: Bool) { + self.panes = panes + self.unconfirmed = unconfirmed + self.sessions = sessions + self.inventoryFailed = inventoryFailed + } + } + + /// `survivors` are the leader pids still alive after the kill and the poll; their panes are the + /// ones whose launch payloads must be suppressed. + public static func outcome(narrowed: Narrowed, survivors: Set, inventoryFailed: Bool) -> Outcome { + var killed = 0, gone = 0, skipped = 0 + var unconfirmed: [UUID] = [] + var resetSessions: Set = [], partialSessions: Set = [], unconfirmedSessions: Set = [] + for (target, disposition) in narrowed.dispositions.sorted(by: { $0.key.daemon < $1.key.daemon }) { + switch disposition { + case .gone: + gone += 1 + resetSessions.insert(target.sessionID) + case .skipped: + skipped += 1 + partialSessions.insert(target.sessionID) + case .kill where survivors.contains(target.leaderPID): + unconfirmed.append(target.paneIdentity) + partialSessions.insert(target.sessionID) + unconfirmedSessions.insert(target.sessionID) + case .kill: + killed += 1 + resetSessions.insert(target.sessionID) + } + } + resetSessions.subtract(partialSessions) + return Outcome( + panes: PaneCounts(confirmed: narrowed.dispositions.count, killed: killed, gone: gone, skipped: skipped), + unconfirmed: unconfirmed, + sessions: SessionCounts(affected: resetSessions.count + partialSessions.count, reset: resetSessions.count, + partial: partialSessions.count, unconfirmed: unconfirmedSessions.count), + inventoryFailed: inventoryFailed) + } + + public static let markerFilename = "live-reset.json" + public static let consumedFilename = "live-reset.consumed.json" + + public static func dialogText(sessionCount: Int) -> (title: String, body: String) { + let noun = sessionCount == 1 ? "live session" : "live sessions" + return (title: "Reset Live Sessions?", + body: "\(sessionCount) \(noun) will be reset. Agterm quits and reopens itself right away with your " + + "sessions and layout. Commands that were running in those sessions are started again where " + + "possible; other work running in them stops, and agent conversations may need to be resumed by hand.") + } + + public static func notificationText(outcome: Outcome) -> String? { + if outcome.inventoryFailed { return "Live sessions were not reset: the session list could not be read." } + guard outcome.sessions.partial > 0 else { return nil } + var text = "The reset covered \(outcome.sessions.reset) of \(outcome.sessions.affected) live sessions. " + + "Run Help ▸ Reset Live Sessions… again for the rest." + if outcome.sessions.unconfirmed > 0 { + let noun = outcome.sessions.unconfirmed == 1 ? "session" : "sessions" + text += " Some previous processes in \(outcome.sessions.unconfirmed) \(noun) may still be running; " + + "those commands were not restarted." + } + return text + } + + public static func menuVisible(configured: RestoreMode, active: RestoreMode) -> Bool { + configured == .live && active == .live + } +} + +/// The one-shot marker on disk. `consume` renames before decoding so a crash mid-reset never replays it. +public struct LiveResetMarkerStore { + public enum Failure: Error, Equatable { + case invalid + } + + private let marker: URL + private let consumed: URL + + public init(directory: URL) { + marker = directory.appendingPathComponent(LiveReset.markerFilename) + consumed = directory.appendingPathComponent(LiveReset.consumedFilename) + } + + public func write(_ value: LiveReset.Marker) throws { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + try encoder.encode(value).write(to: marker, options: .atomic) + } + + /// Nil when no marker exists. Throws `.invalid`, after removing the file, for anything that does not + /// decode as the current version; any other error is the rename failing, and the caller must then + /// treat the reset as not authorized. + public func consume() throws -> LiveReset.Marker? { + let files = FileManager.default + guard files.fileExists(atPath: marker.path) else { return nil } + if files.fileExists(atPath: consumed.path) { try files.removeItem(at: consumed) } + try files.moveItem(at: marker, to: consumed) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + guard let data = try? Data(contentsOf: consumed), + let value = try? decoder.decode(LiveReset.Marker.self, from: data), + value.version == LiveReset.Marker.currentVersion else { + try? files.removeItem(at: consumed) + throw Failure.invalid + } + return value + } + + public func removeConsumed() { + try? FileManager.default.removeItem(at: consumed) + } + + public func remove() { + try? FileManager.default.removeItem(at: marker) + removeConsumed() + } +} diff --git a/agtermCore/Sources/agtermCore/RestoreMode.swift b/agtermCore/Sources/agtermCore/RestoreMode.swift index 6c461d4e4..8efe5bcad 100644 --- a/agtermCore/Sources/agtermCore/RestoreMode.swift +++ b/agtermCore/Sources/agtermCore/RestoreMode.swift @@ -30,4 +30,10 @@ public struct RestoreLaunchDecision: Equatable, Sendable { public let requested: RestoreMode public let active: RestoreMode public let liveUnavailableReason: String? + + public init(requested: RestoreMode, active: RestoreMode, liveUnavailableReason: String?) { + self.requested = requested + self.active = active + self.liveUnavailableReason = liveUnavailableReason + } } diff --git a/agtermCore/Sources/agtermctlKit/ZmxCommands.swift b/agtermCore/Sources/agtermctlKit/ZmxCommands.swift index cb5497b0a..af4d8033a 100644 --- a/agtermCore/Sources/agtermctlKit/ZmxCommands.swift +++ b/agtermCore/Sources/agtermctlKit/ZmxCommands.swift @@ -15,9 +15,41 @@ struct Zmx: ParsableCommand { Every one needs a running agterm: only the app can join its live windows, its pending closes and \ its persisted snapshots against what zmx reports. With agterm stopped there is nothing to ask. """, - subcommands: [List.self, Prune.self, Kill.self, Tree.self, Attach.self] + subcommands: [List.self, Prune.self, Kill.self, Reset.self, Tree.self, Attach.self] ) + struct Reset: RequestCommand { + static let configuration = CommandConfiguration( + abstract: "Reset the live sessions this app does not supervise, then quit and reopen agterm.", + discussion: """ + The same operation as Help > Reset Live Sessions, without the dialog. A live session created \ + before the session host existed keeps its own macOS permission identity, so every new version \ + of a tool in it asks for the microphone again. The reset ends those sessions' processes at the \ + next launch and recreates them under the host, starting their captured commands again where \ + possible. Sessions already supervised are left alone. + + agterm quits and reopens itself right after answering. Running work in the affected sessions \ + stops, and agent conversations may need to be resumed by hand. Run from inside one of those \ + sessions, this kills the shell this agtermctl runs in. + + It refuses outside Live sessions mode, when a mode change is waiting for a restart, when the \ + pane inventory is incomplete, and when nothing needs resetting. The next launch re-checks every \ + session and only ever resets fewer than confirmed; the tree's `liveReset` reports the result. + """) + @Flag(name: .long, help: "Required. Confirms ending the processes in every affected live session.") + var force = false + + @OptionGroup var options: BasicOptions + + func validate() throws { + guard force else { throw ValidationError("--force is required to end the processes in the affected live sessions") } + } + + func makeRequest() throws -> ControlRequest { + ControlRequest(cmd: .zmxReset, args: ControlArgs(force: true)) + } + } + struct Attach: RequestCommand { static let configuration = CommandConfiguration( abstract: "Attach to a session on another Mac, as a session here.", diff --git a/agtermCore/Tests/agtermCoreTests/ControlDispatcherZmxTests.swift b/agtermCore/Tests/agtermCoreTests/ControlDispatcherZmxTests.swift index 9987b7d11..e404841d1 100644 --- a/agtermCore/Tests/agtermCoreTests/ControlDispatcherZmxTests.swift +++ b/agtermCore/Tests/agtermCoreTests/ControlDispatcherZmxTests.swift @@ -346,4 +346,27 @@ struct ControlDispatcherZmxTests { // requirement ships a default returning this rather than breaking that build #expect(ControlActionsUnsupported.message("zmx.list") == "zmx.list is not supported on this platform") } + + @Test func resetRefusesWithoutForce() async throws { + let actions = MockControlActions() + + let response = try #require(await dispatch(ControlRequest(cmd: .zmxReset), actions)) + + #expect(!response.ok) + #expect(response.error == "zmx.reset requires --force") + #expect(actions.calls.isEmpty) + } + + @Test func resetWithForceReachesAction() async throws { + let actions = MockControlActions() + actions.nextZmxResetResponse = ControlResponse( + ok: true, result: ControlResult(text: "2 live sessions will be reset.", + liveReset: ControlLiveResetStatus(sessions: 2, panes: 3, pending: true))) + + let response = try #require(await dispatch(ControlRequest(cmd: .zmxReset, args: ControlArgs(force: true)), actions)) + + #expect(response.ok) + #expect(actions.calls == [.zmxReset]) + #expect(response.result?.liveReset == ControlLiveResetStatus(sessions: 2, panes: 3, pending: true)) + } } diff --git a/agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift b/agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift index 83ad0620b..d26b0ad70 100644 --- a/agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift +++ b/agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift @@ -1953,4 +1953,47 @@ struct ControlProtocolTests { #expect(!encoded.contains("commit")) #expect(try JSONDecoder().decode(AppIdentity.self, from: Data(encoded.utf8)) == commitless) } + + private static let liveResetOutcome = LiveReset.Outcome( + panes: LiveReset.PaneCounts(confirmed: 3, killed: 2, gone: 0, skipped: 0), unconfirmed: [UUID()], + sessions: LiveReset.SessionCounts(affected: 2, reset: 1, partial: 1, unconfirmed: 1), inventoryFailed: false) + + @Test func liveResetStatusRoundTrips() throws { + let response = ControlResponse(ok: true, result: ControlResult( + text: "2 live sessions will be reset.", liveReset: ControlLiveResetStatus(sessions: 2, panes: 3, pending: true))) + let decoded = try roundTrip(response) + #expect(decoded == response) + #expect(decoded.result?.liveReset?.pending == true) + } + + @Test func liveResetReadbackRoundTrips() throws { + let readback = ControlLiveResetReadback(pending: 3, last: Self.liveResetOutcome) + let tree = ControlTree(workspaces: [], liveReset: readback) + let inventory = ControlZmxInventory( + restore: ControlRestoreStatus(configured: .live, requestedAtLaunch: .live, active: .live, unavailableReason: nil), + result: ZmxInventoryResult(rows: [], inventoryComplete: true), liveReset: readback) + let response = ControlResponse(ok: true, result: ControlResult(tree: tree, zmx: inventory)) + + let decoded = try roundTrip(response) + + #expect(decoded == response) + #expect(decoded.result?.tree?.liveReset == readback) + #expect(decoded.result?.zmx?.liveReset == readback) + } + + @Test func liveResetOutcomeRoundTrips() throws { + let data = try JSONEncoder().encode(Self.liveResetOutcome) + #expect(try JSONDecoder().decode(LiveReset.Outcome.self, from: data) == Self.liveResetOutcome) + } + + @Test func liveResetIsOmittedWhenNil() throws { + let tree = try JSONEncoder().encode(ControlTree(workspaces: [])) + let inventory = try JSONEncoder().encode(ControlZmxInventory( + restore: ControlRestoreStatus(configured: .live, requestedAtLaunch: .live, active: .live, unavailableReason: nil), + result: ZmxInventoryResult(rows: [], inventoryComplete: true))) + let result = try JSONEncoder().encode(ControlResult(text: "x")) + for encoded in [tree, inventory, result] { + #expect(!String(decoding: encoded, as: UTF8.self).contains("liveReset")) + } + } } diff --git a/agtermCore/Tests/agtermCoreTests/LiveResetTests.swift b/agtermCore/Tests/agtermCoreTests/LiveResetTests.swift new file mode 100644 index 000000000..8bf51def0 --- /dev/null +++ b/agtermCore/Tests/agtermCoreTests/LiveResetTests.swift @@ -0,0 +1,300 @@ +import Foundation +import Testing +@testable import agtermCore + +struct LiveResetTests { + private static let windowID = UUID() + private static let sessionA = UUID() + private static let sessionB = UUID() + private static let paneA = UUID() + private static let paneASplit = UUID() + private static let paneB = UUID() + + private static func claim(_ pane: UUID, role: ZmxPaneRole = .left, session: UUID = sessionA) -> ZmxPaneClaim { + ZmxPaneClaim(paneIdentity: pane, pane: role, pendingClose: false, windowID: windowID, windowName: "w", + windowState: .open, workspaceID: nil, workspaceName: "default", + sessionID: session, sessionName: "build") + } + + private static func record(_ pane: UUID, leader: Int32?) -> ZmxSessionRecord { + ZmxSessionRecord(name: ZmxSupport.daemonName(for: pane), clients: 0, leaderPID: leader) + } + + private static func target(_ pane: UUID, session: UUID = sessionA, leader: Int32) -> LiveReset.Target { + LiveReset.Target(paneIdentity: pane, sessionID: session, daemon: ZmxSupport.daemonName(for: pane), leaderPID: leader) + } + + private static func classifier(_ table: [String: SessionHost.Attribution]) -> (String, Int32) -> SessionHost.Attribution { + { name, _ in table[name] ?? .unknown } + } + + @Test func selectKeepsOrphanedAndAppPanesOnly() { + let paneC = UUID(), paneD = UUID() + let claims = ZmxClaimWalk(claims: [Self.claim(Self.paneA), Self.claim(Self.paneB, session: Self.sessionB), + Self.claim(paneC), Self.claim(paneD)], complete: true) + let records = [Self.record(Self.paneA, leader: 10), Self.record(Self.paneB, leader: 11), + Self.record(paneC, leader: 12), Self.record(paneD, leader: 13)] + let classify = Self.classifier([ZmxSupport.daemonName(for: Self.paneA): .orphaned, + ZmxSupport.daemonName(for: Self.paneB): .app, + ZmxSupport.daemonName(for: paneC): .supervisor, + ZmxSupport.daemonName(for: paneD): .unknown]) + + let selection = LiveReset.select(claims: claims, records: records, classify: classify) + + #expect(selection.targets == [Self.target(Self.paneA, leader: 10), Self.target(Self.paneB, session: Self.sessionB, leader: 11)]) + #expect(selection.inventoryComplete) + #expect(selection.sessionCount == 2) + } + + @Test func selectExcludesPanesWithoutAReadableLeader() { + let claims = ZmxClaimWalk(claims: [Self.claim(Self.paneA), Self.claim(Self.paneB)], complete: false) + let records = [Self.record(Self.paneA, leader: nil)] + let classify: (String, Int32) -> SessionHost.Attribution = { _, _ in .orphaned } + + let selection = LiveReset.select(claims: claims, records: records, classify: classify) + + #expect(selection.targets.isEmpty) + #expect(!selection.inventoryComplete) + } + + @Test func selectCountsASplitSessionOnce() { + let claims = ZmxClaimWalk(claims: [Self.claim(Self.paneA), Self.claim(Self.paneASplit, role: .right)], complete: true) + let records = [Self.record(Self.paneA, leader: 10), Self.record(Self.paneASplit, leader: 20)] + + let selection = LiveReset.select(claims: claims, records: records, classify: { _, _ in .orphaned }) + + #expect(selection.targets.count == 2) + #expect(selection.sessionCount == 1) + } + + @Test func selectRejectsAPaneClaimedTwice() { + let claims = ZmxClaimWalk(claims: [Self.claim(Self.paneA), Self.claim(Self.paneA, session: Self.sessionB)], complete: true) + let records = [Self.record(Self.paneA, leader: 10)] + + let selection = LiveReset.select(claims: claims, records: records, classify: { _, _ in .orphaned }) + + #expect(selection.targets == [Self.target(Self.paneA, leader: 10)]) + #expect(!selection.inventoryComplete) + } + + private static func marker(_ targets: [LiveReset.Target]) -> LiveReset.Marker { + LiveReset.Marker(targets: targets, createdAt: Date(timeIntervalSince1970: 0)) + } + + @Test func narrowKillsOnlyTheSameLeaderStillOrphaned() { + let marker = Self.marker([Self.target(Self.paneA, leader: 10)]) + let narrowed = LiveReset.narrow(marker: marker, claimed: [Self.paneA], + records: [Self.record(Self.paneA, leader: 10)], classify: { _, _ in .orphaned }) + + #expect(narrowed.dispositions == [Self.target(Self.paneA, leader: 10): .kill]) + #expect(narrowed.kill == [Self.target(Self.paneA, leader: 10)]) + #expect(!narrowed.inventoryFailed) + } + + @Test func narrowMarksAMissingDaemonGone() { + let marker = Self.marker([Self.target(Self.paneA, leader: 10)]) + let narrowed = LiveReset.narrow(marker: marker, claimed: [Self.paneA], records: [], classify: { _, _ in .orphaned }) + + #expect(narrowed.dispositions[Self.target(Self.paneA, leader: 10)] == .gone) + #expect(narrowed.kill.isEmpty) + } + + @Test(arguments: [ + (claimed: false, leader: Int32?.some(10), attribution: SessionHost.Attribution.orphaned), + (claimed: true, leader: Int32?.some(11), attribution: SessionHost.Attribution.orphaned), + (claimed: true, leader: Int32?.none, attribution: SessionHost.Attribution.orphaned), + (claimed: true, leader: Int32?.some(10), attribution: SessionHost.Attribution.supervisor), + (claimed: true, leader: Int32?.some(10), attribution: SessionHost.Attribution.app), + (claimed: true, leader: Int32?.some(10), attribution: SessionHost.Attribution.unknown), + ]) + func narrowSkipsUnclaimedChangedUnreadableOrReattributed(claimed: Bool, leader: Int32?, attribution: SessionHost.Attribution) { + let target = Self.target(Self.paneA, leader: 10) + let narrowed = LiveReset.narrow(marker: Self.marker([target]), claimed: claimed ? [Self.paneA] : [], + records: [Self.record(Self.paneA, leader: leader)], classify: { _, _ in attribution }) + + #expect(narrowed.dispositions[target] == .skipped) + #expect(narrowed.kill.isEmpty) + } + + @Test func narrowWithoutAListingKillsNothingAndReportsInventoryFailure() { + let target = Self.target(Self.paneA, leader: 10) + let narrowed = LiveReset.narrow(marker: Self.marker([target]), claimed: [Self.paneA], records: nil, + classify: { _, _ in .orphaned }) + + #expect(narrowed.inventoryFailed) + #expect(narrowed.kill.isEmpty) + #expect(narrowed.dispositions[target] == .skipped) + } + + @Test func narrowWithoutAListingToleratesADuplicatedMarkerTarget() { + let target = Self.target(Self.paneA, leader: 10) + let narrowed = LiveReset.narrow(marker: Self.marker([target, target]), claimed: [Self.paneA], records: nil, + classify: { _, _ in .orphaned }) + + #expect(narrowed.inventoryFailed) + #expect(narrowed.dispositions == [target: .skipped]) + } + + @Test func narrowWithoutClaimsKillsNothing() { + let target = Self.target(Self.paneA, leader: 10) + let narrowed = LiveReset.narrow(marker: Self.marker([target]), claimed: nil, + records: [Self.record(Self.paneA, leader: 10)], classify: { _, _ in .orphaned }) + + #expect(narrowed.kill.isEmpty) + #expect(narrowed.dispositions[target] == .skipped) + } + + @Test func narrowNeverAddsADaemonAbsentFromTheMarker() { + let target = Self.target(Self.paneA, leader: 10) + let narrowed = LiveReset.narrow(marker: Self.marker([target]), claimed: [Self.paneA, Self.paneB], + records: [Self.record(Self.paneA, leader: 10), Self.record(Self.paneB, leader: 11)], + classify: { _, _ in .orphaned }) + + #expect(narrowed.kill == [target]) + #expect(narrowed.dispositions.count == 1) + } + + @Test func outcomeCountsASessionResetWhenEveryPaneIsConfirmedOrGone() { + let a = Self.target(Self.paneA, leader: 10) + let split = Self.target(Self.paneASplit, leader: 20) + let b = Self.target(Self.paneB, session: Self.sessionB, leader: 30) + let narrowed = LiveReset.Narrowed(dispositions: [a: .kill, split: .gone, b: .kill], inventoryFailed: false) + + let outcome = LiveReset.outcome(narrowed: narrowed, survivors: [], inventoryFailed: false) + + #expect(outcome.panes == LiveReset.PaneCounts(confirmed: 3, killed: 2, gone: 1, skipped: 0)) + #expect(outcome.unconfirmed.isEmpty) + #expect(outcome.sessions == LiveReset.SessionCounts(affected: 2, reset: 2, partial: 0, unconfirmed: 0)) + } + + @Test func outcomeCountsASplitSessionWithOneSurvivorAsOnePartialSession() { + let a = Self.target(Self.paneA, leader: 10) + let split = Self.target(Self.paneASplit, leader: 20) + let narrowed = LiveReset.Narrowed(dispositions: [a: .kill, split: .kill], inventoryFailed: false) + + let outcome = LiveReset.outcome(narrowed: narrowed, survivors: [20], inventoryFailed: false) + + #expect(outcome.panes == LiveReset.PaneCounts(confirmed: 2, killed: 1, gone: 0, skipped: 0)) + #expect(outcome.unconfirmed == [Self.paneASplit]) + #expect(outcome.sessions == LiveReset.SessionCounts(affected: 1, reset: 0, partial: 1, unconfirmed: 1)) + } + + @Test func outcomeCountsTwoSurvivorsInOneSessionOnce() { + let a = Self.target(Self.paneA, leader: 10) + let split = Self.target(Self.paneASplit, leader: 20) + let b = Self.target(Self.paneB, session: Self.sessionB, leader: 30) + let narrowed = LiveReset.Narrowed(dispositions: [a: .kill, split: .kill, b: .skipped], inventoryFailed: false) + + let outcome = LiveReset.outcome(narrowed: narrowed, survivors: [10, 20], inventoryFailed: false) + + #expect(Set(outcome.unconfirmed) == [Self.paneA, Self.paneASplit]) + #expect(outcome.sessions == LiveReset.SessionCounts(affected: 2, reset: 0, partial: 2, unconfirmed: 1)) + #expect(outcome.panes.skipped == 1) + } + + private func makeTempDir() throws -> URL { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("agterm-live-reset-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + @Test func markerStoreWritesThenConsumesOnce() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let store = LiveResetMarkerStore(directory: dir) + let marker = Self.marker([Self.target(Self.paneA, leader: 10)]) + + try store.write(marker) + #expect(FileManager.default.fileExists(atPath: dir.appendingPathComponent(LiveReset.markerFilename).path)) + + let consumed = try store.consume() + #expect(consumed == marker) + #expect(!FileManager.default.fileExists(atPath: dir.appendingPathComponent(LiveReset.markerFilename).path)) + #expect(FileManager.default.fileExists(atPath: dir.appendingPathComponent(LiveReset.consumedFilename).path)) + + #expect(try store.consume() == nil) + + store.removeConsumed() + #expect(!FileManager.default.fileExists(atPath: dir.appendingPathComponent(LiveReset.consumedFilename).path)) + } + + @Test(arguments: ["not json", "{\"version\":2,\"createdAt\":0,\"targets\":[]}"]) + func markerStoreRemovesAnInvalidMarkerAndReportsIt(contents: String) throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let store = LiveResetMarkerStore(directory: dir) + try contents.write(to: dir.appendingPathComponent(LiveReset.markerFilename), atomically: true, encoding: .utf8) + + #expect(throws: LiveResetMarkerStore.Failure.invalid) { try store.consume() } + #expect(!FileManager.default.fileExists(atPath: dir.appendingPathComponent(LiveReset.markerFilename).path)) + #expect(!FileManager.default.fileExists(atPath: dir.appendingPathComponent(LiveReset.consumedFilename).path)) + } + + @Test func markerStoreRemoveClearsBothFiles() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let store = LiveResetMarkerStore(directory: dir) + try store.write(Self.marker([])) + _ = try store.consume() + try store.write(Self.marker([])) + + store.remove() + + #expect(!FileManager.default.fileExists(atPath: dir.appendingPathComponent(LiveReset.markerFilename).path)) + #expect(!FileManager.default.fileExists(atPath: dir.appendingPathComponent(LiveReset.consumedFilename).path)) + } + + @Test(arguments: [(1, "1 live session will be reset."), (2, "2 live sessions will be reset.")]) + func dialogTextCountsSessions(count: Int, opening: String) { + let text = LiveReset.dialogText(sessionCount: count) + #expect(text.title == "Reset Live Sessions?") + #expect(text.body.hasPrefix(opening)) + #expect(text.body.contains("quits and reopens itself")) + #expect(!text.body.lowercased().contains("zmx")) + } + + @Test func notificationIsSilentWhenEverySessionWasReset() { + let outcome = LiveReset.Outcome(panes: .init(confirmed: 2, killed: 2, gone: 0, skipped: 0), unconfirmed: [], + sessions: .init(affected: 2, reset: 2, partial: 0, unconfirmed: 0), inventoryFailed: false) + #expect(LiveReset.notificationText(outcome: outcome) == nil) + } + + @Test func notificationReportsAPartialResetWithoutSurvivors() { + let outcome = LiveReset.Outcome(panes: .init(confirmed: 3, killed: 2, gone: 0, skipped: 1), unconfirmed: [], + sessions: .init(affected: 3, reset: 2, partial: 1, unconfirmed: 0), inventoryFailed: false) + let text = LiveReset.notificationText(outcome: outcome) + #expect(text == "The reset covered 2 of 3 live sessions. Run Help ▸ Reset Live Sessions… again for the rest.") + } + + @Test func notificationReportsSurvivorsBySession() { + let outcome = LiveReset.Outcome(panes: .init(confirmed: 3, killed: 1, gone: 0, skipped: 0), + unconfirmed: [Self.paneA, Self.paneASplit], + sessions: .init(affected: 2, reset: 1, partial: 1, unconfirmed: 1), inventoryFailed: false) + let text = LiveReset.notificationText(outcome: outcome) + #expect(text == "The reset covered 1 of 2 live sessions. Run Help ▸ Reset Live Sessions… again for the rest. " + + "Some previous processes in 1 session may still be running; those commands were not restarted.") + } + + @Test func notificationForAMixedSessionDoesNotClaimEveryCommandStayedDown() { + let a = Self.target(Self.paneA, leader: 10) + let split = Self.target(Self.paneASplit, leader: 20) + let narrowed = LiveReset.Narrowed(dispositions: [a: .kill, split: .kill], inventoryFailed: false) + let outcome = LiveReset.outcome(narrowed: narrowed, survivors: [20], inventoryFailed: false) + + let text = LiveReset.notificationText(outcome: outcome) + + #expect(text == "The reset covered 0 of 1 live sessions. Run Help ▸ Reset Live Sessions… again for the rest. " + + "Some previous processes in 1 session may still be running; those commands were not restarted.") + } + + @Test func notificationReportsAnUnreadableSessionList() { + let outcome = LiveReset.Outcome(panes: .init(confirmed: 1, killed: 0, gone: 0, skipped: 1), unconfirmed: [], + sessions: .init(affected: 1, reset: 0, partial: 1, unconfirmed: 0), inventoryFailed: true) + #expect(LiveReset.notificationText(outcome: outcome) == "Live sessions were not reset: the session list could not be read.") + } + + @Test(arguments: RestoreMode.allCases, RestoreMode.allCases) + func menuIsVisibleOnlyWhenBothModesAreLive(configured: RestoreMode, active: RestoreMode) { + #expect(LiveReset.menuVisible(configured: configured, active: active) == (configured == .live && active == .live)) + } +} diff --git a/agtermCore/Tests/agtermCoreTests/MockControlActions.swift b/agtermCore/Tests/agtermCoreTests/MockControlActions.swift index 0a09cbc19..cc91f2a1f 100644 --- a/agtermCore/Tests/agtermCoreTests/MockControlActions.swift +++ b/agtermCore/Tests/agtermCoreTests/MockControlActions.swift @@ -57,6 +57,7 @@ final class MockControlActions: ControlActions { case zmxList case zmxPrune case zmxKill(target: String, window: String?, pane: ZmxPaneRole) + case zmxReset case zmxTree(host: String?) case zmxAttach(host: String, session: String) case sidebarVisibility(ControlToggleMode) @@ -137,6 +138,7 @@ final class MockControlActions: ControlActions { var nextZmxListResponse = ControlResponse(ok: true) var nextZmxPruneResponse = ControlResponse(ok: true) var nextZmxKillResponse = ControlResponse(ok: true) + var nextZmxResetResponse = ControlResponse(ok: true) var nextRemoteTreeResponse = ControlResponse(ok: true) var nextRemoteAttachResponse = ControlResponse(ok: true) var nextQuickResponse = ControlResponse(ok: true) @@ -441,6 +443,11 @@ final class MockControlActions: ControlActions { return nextZmxKillResponse } + func resetLiveSessions() -> ControlResponse { + calls.append(.zmxReset) + return nextZmxResetResponse + } + func remoteTree(host: String?) async -> ControlResponse { calls.append(.zmxTree(host: host)) return nextRemoteTreeResponse diff --git a/agtermCore/Tests/agtermctlKitTests/ZmxCommandsTests.swift b/agtermCore/Tests/agtermctlKitTests/ZmxCommandsTests.swift index 25b88d085..14b3865be 100644 --- a/agtermCore/Tests/agtermctlKitTests/ZmxCommandsTests.swift +++ b/agtermCore/Tests/agtermctlKitTests/ZmxCommandsTests.swift @@ -174,4 +174,17 @@ struct ZmxCommandsTests { #expect(rendered.contains("inventory incomplete")) #expect(rendered.contains("no daemons")) } + + @Test func resetEncodesForce() throws { + let request = try Zmx.Reset.parse(["--force"]).makeRequest() + + #expect(request.cmd == .zmxReset) + #expect(request.args?.force == true) + #expect(request.target == nil, "the reset is app-global and takes no target") + #expect(try JSONDecoder().decode(ControlRequest.self, from: JSONEncoder().encode(request)) == request) + } + + @Test func resetRefusesWithoutForce() { + #expect(throws: (any Error).self) { try Zmx.Reset.parse([]) } + } } diff --git a/agtermTests/ControlServerLiveResetTests.swift b/agtermTests/ControlServerLiveResetTests.swift new file mode 100644 index 000000000..d08eb9c5f --- /dev/null +++ b/agtermTests/ControlServerLiveResetTests.swift @@ -0,0 +1,335 @@ +import AppKit +import Darwin +import XCTest +@testable import agterm +import agtermCore +import AgtermResponsibility + +@MainActor +final class ControlServerLiveResetTests: XCTestCase { + private var stateDir: URL! + private var library: WindowLibrary! + private var settingsModel: SettingsModel! + private var socketPath: String! + private var server: ControlServer? + + override func setUp() async throws { + try await super.setUp() + await MainActor.run { + stateDir = FileManager.default.temporaryDirectory + .appendingPathComponent("agterm-live-reset-tests-\(UUID().uuidString)", isDirectory: true) + library = WindowLibrary(directory: stateDir) + settingsModel = SettingsModel(library: library, settingsStore: SettingsStore(directory: stateDir)) + socketPath = "/tmp/agterm-lr-\(UUID().uuidString.prefix(8)).sock" + } + } + + override func tearDown() async throws { + await MainActor.run { + server?.stop() + server = nil + unlink(socketPath) + unlink(socketPath + ".lock") + settingsModel = nil + library = nil + try? FileManager.default.removeItem(at: stateDir) + } + try await super.tearDown() + } + + private static let orphanProbe = LiveAttributionProbe(responsible: { .live($0) }, hostPID: { _ in nil }, appPID: 300) + + private func makeCoordinator(active: RestoreMode = .live, configured: RestoreMode = .live) -> LiveResetCoordinator { + XCTAssertTrue(settingsModel.setRestoreMode(configured)) + return LiveResetCoordinator(settingsModel: settingsModel, selection: { nil }, activeMode: { active }, terminate: {}) + } + + private func makeServer(liveReset: LiveResetCoordinator, runner: @escaping ZmxClient.Runner, + probe: LiveAttributionProbe = orphanProbe, + remoteRunner: (any RemoteCommandRunner)? = nil, + responseWriter: @escaping ControlServer.ResponseWriter = ControlServer.writeResponse) -> ControlServer { + let client = ZmxClient(executablePath: "/tmp/zmx", socketDirectory: "/tmp/zmx-dir", runner: runner) + let resolver = ZmxForegroundResolver(leaderProvider: { _ in [:] }, leaderProbe: { .foreground($0) }) + let server = ControlServer(library: library, actions: AppActions(library: library), settingsModel: settingsModel, + identity: AppIdentity(version: "test", commit: "test"), zmxForegroundResolver: resolver, + zmxClient: client, liveAttributionProbe: probe, remoteRunner: remoteRunner, + socketPath: socketPath, responseWriter: responseWriter) + liveReset.selection = { [weak server] in server?.liveResetSelection() } + server.liveReset = liveReset + self.server = server + return server + } + + private func addOrphanedSession() throws -> (session: Session, rows: String) { + let store = try XCTUnwrap(library.activeStore) + let workspace = try XCTUnwrap(store.workspaces.first) + let session = try XCTUnwrap(store.addSession(toWorkspace: workspace.id, cwd: "/tmp")) + return (session, "name=\(ZmxSupport.daemonName(for: session.paneIdentity))\tpid=200\tclients=0") + } + + func testLiveResetSelectionJoinsClaimsAndRecords() throws { + let store = try XCTUnwrap(library.activeStore) + let workspace = try XCTUnwrap(store.workspaces.first) + let orphaned = try XCTUnwrap(store.addSession(toWorkspace: workspace.id, cwd: "/tmp")) + let supervised = try XCTUnwrap(store.addSession(toWorkspace: workspace.id, cwd: "/tmp")) + let rows = [ + "name=\(ZmxSupport.daemonName(for: orphaned.paneIdentity))\tpid=200\tclients=0", + "name=\(ZmxSupport.daemonName(for: supervised.paneIdentity))\tpid=210\tclients=1", + ].joined(separator: "\n") + let probe = LiveAttributionProbe(responsible: { pid in pid == 210 ? .live(100) : .live(pid) }, + hostPID: { _ in 100 }, appPID: 300) + + let selection = try XCTUnwrap(makeServer(liveReset: makeCoordinator(), runner: { _ in rows }, probe: probe).liveResetSelection()) + + XCTAssertEqual(selection.targets, [LiveReset.Target(paneIdentity: orphaned.paneIdentity, sessionID: orphaned.id, + daemon: ZmxSupport.daemonName(for: orphaned.paneIdentity), leaderPID: 200)]) + XCTAssertEqual(selection.sessionCount, 1) + XCTAssertTrue(selection.inventoryComplete) + } + + func testLiveResetSelectionIsNilWhenTheListingFails() { + XCTAssertNil(makeServer(liveReset: makeCoordinator(), runner: { _ in throw ZmxClient.CommandError.timedOut }).liveResetSelection()) + } + + func testResetRefusedOutsideLive() throws { + let fixture = try addOrphanedSession() + for (active, configured) in [(RestoreMode.rerun, RestoreMode.live), (.live, .rerun), (.none, .none)] { + let liveReset = makeCoordinator(active: active, configured: configured) + let response = makeServer(liveReset: liveReset, runner: { _ in fixture.rows }).resetLiveSessions() + XCTAssertFalse(response.ok) + XCTAssertEqual(response.error, LiveResetCoordinator.Refusal.notLive.message) + XCTAssertNil(liveReset.pending) + } + } + + func testResetRefusedWhenTheListingFails() throws { + let liveReset = makeCoordinator() + let response = makeServer(liveReset: liveReset, runner: { _ in throw ZmxClient.CommandError.timedOut }).resetLiveSessions() + XCTAssertEqual(response.error, LiveResetCoordinator.Refusal.listingFailed.message) + XCTAssertNil(liveReset.pending) + } + + func testResetRefusedOnIncompleteInventory() throws { + let fixture = try addOrphanedSession() + let windows = stateDir.appendingPathComponent("windows") + try? FileManager.default.removeItem(at: windows) + try "not a directory".write(to: windows, atomically: true, encoding: .utf8) + let liveReset = makeCoordinator() + + let response = makeServer(liveReset: liveReset, runner: { _ in fixture.rows }).resetLiveSessions() + + XCTAssertEqual(response.error, LiveResetCoordinator.Refusal.inventoryIncomplete.message) + XCTAssertNil(liveReset.pending) + } + + func testResetRefusedWhenEmpty() throws { + let liveReset = makeCoordinator() + let response = makeServer(liveReset: liveReset, runner: { _ in "" }).resetLiveSessions() + XCTAssertEqual(response.error, LiveResetCoordinator.Refusal.nothingToReset.message) + XCTAssertNil(liveReset.pending) + } + + private func sendTask(_ request: ControlRequest) -> Task { + Self.detachedRoundTrip(request, at: socketPath) + } + + private func send(_ request: ControlRequest) async -> ControlResponse? { + await sendTask(request).value + } + + nonisolated private static func detachedRoundTrip(_ request: ControlRequest, at path: String) -> Task { + Task.detached { [request, path] in roundTrip(request, at: path) } + } + + nonisolated private static func roundTrip(_ request: ControlRequest, at path: String) -> ControlResponse? { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return nil } + defer { close(fd) } + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let capacity = MemoryLayout.size(ofValue: addr.sun_path) + withUnsafeMutablePointer(to: &addr.sun_path) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: capacity) { chars in + _ = strlcpy(chars, path, capacity) + } + } + let connected = withUnsafePointer(to: &addr) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(fd, $0, socklen_t(MemoryLayout.size)) == 0 } + } + guard connected, var payload = try? JSONEncoder().encode(request) else { return nil } + payload.append(UInt8(ascii: "\n")) + let written = payload.withUnsafeBytes { Darwin.write(fd, $0.baseAddress, $0.count) } + guard written == payload.count else { return nil } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while true { + let count = buffer.withUnsafeMutableBufferPointer { Darwin.read(fd, $0.baseAddress, $0.count) } + guard count > 0 else { break } + data.append(contentsOf: buffer[0.. (expectation: XCTestExpectation, log: TerminationLog) { + let expectation = expectation(description: "terminate") + let log = TerminationLog() + liveReset.terminate = { log.count += 1; expectation.fulfill() } + return (expectation, log) + } + + func testResetReplyCarriesCountsAndText() async throws { + let fixture = try addOrphanedSession() + let liveReset = makeCoordinator() + let terminated = expectTermination(of: liveReset) + makeServer(liveReset: liveReset, runner: { _ in fixture.rows }).start() + + let sent = await send(ControlRequest(cmd: .zmxReset, args: ControlArgs(force: true))) + let response = try XCTUnwrap(sent) + + XCTAssertTrue(response.ok, response.error ?? "") + XCTAssertEqual(response.result?.liveReset, ControlLiveResetStatus(sessions: 1, panes: 1, pending: true)) + XCTAssertEqual(response.result?.text?.hasPrefix("1 live session will be reset."), true) + XCTAssertEqual(liveReset.pending?.targets.count, 1) + await fulfillment(of: [terminated.expectation], timeout: 2) + XCTAssertEqual(terminated.log.count, 1) + } + + func testTerminationWaitsForReplyWrite() async throws { + let fixture = try addOrphanedSession() + let liveReset = makeCoordinator() + let terminated = expectTermination(of: liveReset) + let gate = DispatchSemaphore(value: 0) + defer { gate.signal() } + let writerEntered = expectation(description: "reset writer held") + makeServer(liveReset: liveReset, runner: { _ in fixture.rows }, responseWriter: { conn, response in + if response.result?.liveReset != nil { + writerEntered.fulfill() + gate.wait() + } + return ControlServer.writeResponse(conn, response) + }).start() + + let reply = sendTask(ControlRequest(cmd: .zmxReset, args: ControlArgs(force: true))) + await fulfillment(of: [writerEntered], timeout: 2) + XCTAssertEqual(terminated.log.count, 0, "the quit must wait for the reply frame") + gate.signal() + let replied = await reply.value + let response = try XCTUnwrap(replied) + + XCTAssertTrue(response.ok) + await fulfillment(of: [terminated.expectation], timeout: 2) + XCTAssertEqual(terminated.log.count, 1) + } + + func testUnrelatedReplyDuringHeldWriteDoesNotTerminate() async throws { + let fixture = try addOrphanedSession() + let liveReset = makeCoordinator() + let terminated = expectTermination(of: liveReset) + let resetGate = DispatchSemaphore(value: 0) + let remoteEntered = expectation(description: "remote runner held") + let remote = HeldRemoteRunner(onEnter: { remoteEntered.fulfill() }) + defer { + resetGate.signal() + remote.release() + } + let writerEntered = expectation(description: "reset writer held") + makeServer(liveReset: liveReset, runner: { _ in fixture.rows }, remoteRunner: remote, responseWriter: { conn, response in + if response.result?.liveReset != nil { + writerEntered.fulfill() + resetGate.wait() + } + return ControlServer.writeResponse(conn, response) + }).start() + + let tree = sendTask(ControlRequest(cmd: .zmxTree, args: ControlArgs(host: "buildbox"))) + await fulfillment(of: [remoteEntered], timeout: 2) + let reset = sendTask(ControlRequest(cmd: .zmxReset, args: ControlArgs(force: true))) + await fulfillment(of: [writerEntered], timeout: 2) + remote.release() + let treeReplied = await tree.value + let treeResponse = try XCTUnwrap(treeReplied, "the unrelated reply must be written while the reset reply is held") + XCTAssertFalse(treeResponse.ok) + XCTAssertEqual(terminated.log.count, 0, "another reply finishing must not quit the app") + resetGate.signal() + let replied = await reset.value + let response = try XCTUnwrap(replied) + + XCTAssertTrue(response.ok) + await fulfillment(of: [terminated.expectation], timeout: 2) + XCTAssertEqual(terminated.log.count, 1) + } + + private static let lastOutcome = LiveReset.Outcome( + panes: LiveReset.PaneCounts(confirmed: 2, killed: 1, gone: 0, skipped: 1), unconfirmed: [], + sessions: LiveReset.SessionCounts(affected: 2, reset: 1, partial: 1, unconfirmed: 0), inventoryFailed: false) + + func testTreeLiveResetReadback() throws { + let fixture = try addOrphanedSession() + let liveReset = makeCoordinator() + let server = makeServer(liveReset: liveReset, runner: { _ in fixture.rows }) + server.liveResetOutcome = { nil } + + XCTAssertNil(server.controlTree(window: nil).result?.tree?.liveReset, "an untouched instance shows no field") + + server.liveResetOutcome = { Self.lastOutcome } + XCTAssertEqual(server.controlTree(window: nil).result?.tree?.liveReset, ControlLiveResetReadback(pending: nil, last: Self.lastOutcome)) + + XCTAssertEqual(liveReset.request(confirmed: true), .confirmed(try XCTUnwrap(server.liveResetSelection()))) + XCTAssertEqual(server.controlTree(window: nil).result?.tree?.liveReset, ControlLiveResetReadback(pending: 1, last: Self.lastOutcome)) + } + + func testZmxListLiveResetReadback() throws { + let fixture = try addOrphanedSession() + let liveReset = makeCoordinator() + let server = makeServer(liveReset: liveReset, runner: { _ in fixture.rows }) + server.liveResetOutcome = { nil } + + XCTAssertNil(try XCTUnwrap(server.listZmxDaemons().result?.zmx).liveReset) + + _ = liveReset.request(confirmed: true) + XCTAssertEqual(try XCTUnwrap(server.listZmxDaemons().result?.zmx).liveReset, ControlLiveResetReadback(pending: 1, last: nil)) + } + + func testFailedReplyWriteDoesNotTerminate() async throws { + let fixture = try addOrphanedSession() + let liveReset = makeCoordinator() + var terminations = 0 + liveReset.terminate = { terminations += 1 } + makeServer(liveReset: liveReset, runner: { _ in fixture.rows }, responseWriter: { conn, response in + response.result?.liveReset != nil ? false : ControlServer.writeResponse(conn, response) + }).start() + + _ = await send(ControlRequest(cmd: .zmxReset, args: ControlArgs(force: true))) + try await Task.sleep(for: .milliseconds(300)) + + XCTAssertEqual(terminations, 0) + XCTAssertNotNil(liveReset.pending, "a reply that never went out leaves the reset pending for a later quit") + } +} + +private final class HeldRemoteRunner: RemoteCommandRunner, @unchecked Sendable { + private let lock = NSLock() + private var released = false + private let onEnter: @Sendable () -> Void + + init(onEnter: @escaping @Sendable () -> Void) { + self.onEnter = onEnter + } + + func release() { + lock.withLock { released = true } + } + + func run(_: [String], deadline _: TimeInterval) async -> RemoteCommandResult { + onEnter() + while !lock.withLock({ released }) { + try? await Task.sleep(for: .milliseconds(20)) + } + return RemoteCommandResult(status: 1, stdout: "", stderr: "held") + } +} diff --git a/agtermTests/LaunchSeedTests.swift b/agtermTests/LaunchSeedTests.swift index 8afaeca3d..e0c819166 100644 --- a/agtermTests/LaunchSeedTests.swift +++ b/agtermTests/LaunchSeedTests.swift @@ -239,9 +239,57 @@ final class LaunchSeedTests: XCTestCase { } private func wrappedProvider(session: Session, pane: StatusPane, denylist: Set = [], - runningNames: Set? = nil) -> LaunchSeedProvider { + runningNames: Set? = nil, suppressed: Bool = false) -> LaunchSeedProvider { LaunchSeedProvider.pane( session: session, pane: pane, disposition: .wrapped(configuration), - policy: .init(restoreEnabled: true, denylist: denylist, runningNames: runningNames)) + policy: .init(restoreEnabled: true, denylist: denylist, runningNames: runningNames, + suppressedDaemons: suppressed ? [configuration.daemonName] : [])) + } + + func testSuppressedPrimaryAttachesWithoutReplayOrCommand() throws { + let session = restoredSession() + session.pendingForegroundCommand = ["npm", "run", "dev"] + session.initialCommand = "htop" + + let provider = wrappedProvider(session: session, pane: .left, suppressed: true) + + XCTAssertFalse(provider.shouldPace) + let command = try XCTUnwrap(provider.resolve(.left).command) + XCTAssertFalse(command.contains("npm")) + XCTAssertFalse(command.contains("htop")) + } + + func testSuppressedSplitAttachesWithoutReplayOrCommand() throws { + let session = restoredSession() + session.pendingSplitForegroundCommand = ["tail", "-f", "log"] + session.splitInitialCommand = "htop" + + let provider = wrappedProvider(session: session, pane: .right, suppressed: true) + + XCTAssertFalse(provider.shouldPace) + let command = try XCTUnwrap(provider.resolve(.right).command) + XCTAssertFalse(command.contains("tail")) + XCTAssertFalse(command.contains("htop")) + } + + func testSuppressionConsumesReplayAndKeepsDurableCommand() { + let session = restoredSession() + session.pendingForegroundCommand = ["npm", "run", "dev"] + session.initialCommand = "htop" + + _ = wrappedProvider(session: session, pane: .left, suppressed: true).resolve(.left) + + XCTAssertNil(session.pendingForegroundCommand, "a suppressed replay is consumed, never left armed for a later spawn") + XCTAssertEqual(session.initialCommand, "htop", "the user's durable command survives the suppression") + } + + func testConfirmedPaneKeepsReplayInSameRun() throws { + let session = restoredSession() + session.pendingForegroundCommand = ["npm", "run", "dev"] + + let provider = wrappedProvider(session: session, pane: .left) + + XCTAssertTrue(provider.shouldPace) + XCTAssertTrue(try XCTUnwrap(provider.resolve(.left).command).contains("npm")) } } diff --git a/agtermTests/LiveResetConsumerTests.swift b/agtermTests/LiveResetConsumerTests.swift new file mode 100644 index 000000000..178144af1 --- /dev/null +++ b/agtermTests/LiveResetConsumerTests.swift @@ -0,0 +1,265 @@ +import Darwin +import XCTest +@testable import agterm +import agtermCore + +@MainActor +final class LiveResetConsumerTests: XCTestCase { + private var stateDir: URL! + private var library: WindowLibrary! + private var context: agtermApp.LaunchSpawnContext! + private var store: LiveResetMarkerStore! + private var invocations: [[String]] = [] + private var timeouts: [String: TimeInterval] = [:] + private var listDelay: Duration = .zero + private var rows: [String] = [] + private var listFails = false + private var killFails = false + private var alive: Set = [] + private var clock = ContinuousClock.Instant.now + + override func setUp() async throws { + try await super.setUp() + await MainActor.run { + stateDir = FileManager.default.temporaryDirectory + .appendingPathComponent("agterm-live-reset-consumer-\(UUID().uuidString)", isDirectory: true) + context = agtermApp.LaunchSpawnContext() + let context = context! + library = WindowLibrary(directory: stateDir, paneFinalizer: nil, launchInventorySink: { context.launchInventory = $0 }) + store = LiveResetMarkerStore(directory: stateDir) + invocations = [] + timeouts = [:] + listDelay = .zero + rows = [] + listFails = false + killFails = false + alive = [] + } + } + + override func tearDown() async throws { + await MainActor.run { + library = nil + try? FileManager.default.removeItem(at: stateDir) + } + try await super.tearDown() + } + + private func makeClient() -> ZmxClient { + let marker = stateDir.appendingPathComponent(LiveReset.markerFilename) + return ZmxClient(executablePath: "/tmp/zmx", socketDirectory: "/tmp/zmx-dir") { [self] invocation in + invocations.append(invocation.arguments) + timeouts[invocation.arguments[0]] = invocation.timeout + switch invocation.arguments.first { + case "list": + clock = clock.advanced(by: listDelay) + if listFails { throw ZmxClient.CommandError.timedOut } + return rows.joined(separator: "\n") + case "kill": + XCTAssertFalse(FileManager.default.fileExists(atPath: marker.path), "the marker is consumed before any kill") + if killFails { throw ZmxClient.CommandError.failed(1, "boom") } + return "" + default: + return "" + } + } + } + + private func dependencies() -> LiveResetConsumer.Dependencies { + var deps = LiveResetConsumer.Dependencies( + markerStore: store, + probe: LiveAttributionProbe(responsible: { .live($0) }, hostPID: { _ in nil }, appPID: 300)) + deps.poll = ZmxClient.LeaderPoll(now: { [self] in clock }, sleep: { [self] in clock = clock.advanced(by: $0) }) + deps.isAlive = { [self] in alive.contains($0) } + return deps + } + + private func addSession() throws -> Session { + let store = try XCTUnwrap(library.activeStore) + let workspace = try XCTUnwrap(store.workspaces.first) + return try XCTUnwrap(store.addSession(toWorkspace: workspace.id, cwd: "/tmp")) + } + + private func target(_ session: Session, leader: Int32) -> LiveReset.Target { + LiveReset.Target(paneIdentity: session.paneIdentity, sessionID: session.id, + daemon: ZmxSupport.daemonName(for: session.paneIdentity), leaderPID: leader) + } + + private func row(_ session: Session, leader: Int32) -> String { + "name=\(ZmxSupport.daemonName(for: session.paneIdentity))\tpid=\(leader)\tclients=0" + } + + private func run() -> LiveReset.Outcome? { + LiveResetConsumer.run(dependencies(), library: library, client: makeClient(), context: context) + } + + private var kills: [[String]] { invocations.filter { $0.first == "kill" } } + + func testNoMarkerMeansNoZmxInvocation() { + XCTAssertNil(run()) + XCTAssertTrue(invocations.isEmpty) + } + + func testMarkerConsumedBeforeFirstKill() throws { + let session = try addSession() + rows = [row(session, leader: 10)] + try store.write(LiveReset.Marker(targets: [target(session, leader: 10)])) + + let outcome = try XCTUnwrap(run()) + + XCTAssertEqual(kills, [["kill", ZmxSupport.daemonName(for: session.paneIdentity), "--force"]]) + XCTAssertEqual(outcome.panes.killed, 1) + } + + func testOnlyNarrowedTargetsKilledInOneInvocation() throws { + let kept = try addSession() + let changed = try addSession() + let unclaimed = LiveReset.Target(paneIdentity: UUID(), sessionID: UUID(), daemon: "agterm-unclaimed", leaderPID: 30) + rows = [row(kept, leader: 10), row(changed, leader: 21), "name=agterm-unclaimed\tpid=30\tclients=0"] + try store.write(LiveReset.Marker(targets: [target(kept, leader: 10), target(changed, leader: 20), unclaimed])) + + let outcome = try XCTUnwrap(run()) + + XCTAssertEqual(kills, [["kill", ZmxSupport.daemonName(for: kept.paneIdentity), "--force"]]) + XCTAssertEqual(outcome.panes, LiveReset.PaneCounts(confirmed: 3, killed: 1, gone: 0, skipped: 2)) + } + + func testFailedListingKillsNothing() throws { + let session = try addSession() + listFails = true + try store.write(LiveReset.Marker(targets: [target(session, leader: 10)])) + + let outcome = try XCTUnwrap(run()) + + XCTAssertTrue(kills.isEmpty) + XCTAssertTrue(outcome.inventoryFailed) + XCTAssertEqual(outcome.panes.skipped, 1) + } + + func testFailedBatchStillPollsEveryLeader() throws { + let exited = try addSession() + let survivor = try addSession() + rows = [row(exited, leader: 10), row(survivor, leader: 20)] + killFails = true + alive = [20] + try store.write(LiveReset.Marker(targets: [target(exited, leader: 10), target(survivor, leader: 20)])) + + let outcome = try XCTUnwrap(run()) + + XCTAssertEqual(kills.count, 1) + XCTAssertEqual(outcome.panes.killed, 1) + XCTAssertEqual(outcome.unconfirmed, [survivor.paneIdentity]) + XCTAssertEqual(context.suppressedLaunchPayloads, [survivor.paneIdentity]) + } + + func testSurvivingLeaderIsUnconfirmedAndSuppressed() throws { + let session = try addSession() + rows = [row(session, leader: 10)] + alive = [10] + try store.write(LiveReset.Marker(targets: [target(session, leader: 10)])) + let start = clock + + let outcome = try XCTUnwrap(run()) + + XCTAssertEqual(outcome.unconfirmed, [session.paneIdentity]) + XCTAssertEqual(outcome.sessions, LiveReset.SessionCounts(affected: 1, reset: 0, partial: 1, unconfirmed: 1)) + XCTAssertEqual(context.suppressedLaunchPayloads, [session.paneIdentity]) + XCTAssertGreaterThanOrEqual(clock, start.advanced(by: .seconds(15))) + XCTAssertLessThan(clock, start.advanced(by: .seconds(16))) + } + + func testInvalidMarkerRemovedAndKillsNothing() throws { + try "not json".write(to: stateDir.appendingPathComponent(LiveReset.markerFilename), atomically: true, encoding: .utf8) + + XCTAssertNil(run()) + + XCTAssertTrue(invocations.isEmpty) + XCTAssertFalse(FileManager.default.fileExists(atPath: stateDir.appendingPathComponent(LiveReset.markerFilename).path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: stateDir.appendingPathComponent(LiveReset.consumedFilename).path)) + } + + func testConsumedMarkerDeletedAfterOutcome() throws { + let session = try addSession() + rows = [row(session, leader: 10)] + try store.write(LiveReset.Marker(targets: [target(session, leader: 10)])) + + XCTAssertNotNil(run()) + + XCTAssertFalse(FileManager.default.fileExists(atPath: stateDir.appendingPathComponent(LiveReset.consumedFilename).path)) + } + + func testBudgetClampsListingAndKill() throws { + let session = try addSession() + rows = [row(session, leader: 10)] + listDelay = .milliseconds(1500) + try store.write(LiveReset.Marker(targets: [target(session, leader: 10)])) + var deps = dependencies() + deps.budget = .seconds(2) + + let outcome = try XCTUnwrap(LiveResetConsumer.run(deps, library: library, client: makeClient(), context: context)) + + XCTAssertEqual(outcome.panes.killed, 1) + XCTAssertEqual(timeouts["list"], 2) + XCTAssertEqual(try XCTUnwrap(timeouts["kill"]), 0.5, accuracy: 0.01) + } + + func testExpiredBudgetSkipsTheBatchAndSuppresses() throws { + let session = try addSession() + rows = [row(session, leader: 10)] + listDelay = .seconds(3) + try store.write(LiveReset.Marker(targets: [target(session, leader: 10)])) + var deps = dependencies() + deps.budget = .seconds(2) + + let outcome = try XCTUnwrap(LiveResetConsumer.run(deps, library: library, client: makeClient(), context: context)) + + XCTAssertTrue(kills.isEmpty, "a batch never starts after the budget expired") + XCTAssertEqual(outcome.unconfirmed, [session.paneIdentity]) + XCTAssertEqual(context.suppressedLaunchPayloads, [session.paneIdentity]) + } + + func testFallbackLaunchDiscardsTheMarkerWithoutKilling() throws { + let seeded = try addSession() + library.saveAllOpen() + library.saveIndex() + let context = context! + library = WindowLibrary(directory: stateDir, paneFinalizer: nil, launchInventorySink: { context.launchInventory = $0 }) + let session = try XCTUnwrap(library.activeStore?.workspaces.flatMap(\.sessions).first { $0.paneIdentity == seeded.paneIdentity }) + rows = [row(session, leader: 10)] + try store.write(LiveReset.Marker(targets: [target(session, leader: 10)])) + let resolver = ZmxForegroundResolver(leaderProvider: { _ in [:] }, leaderProbe: { .foreground($0) }) + let launch = LaunchOrchestration.Inputs(library: library, client: makeClient(), resolver: resolver, context: context, + launchDecision: RestoreLaunchDecision(requested: .live, active: .rerun, liveUnavailableReason: "unsupported shell")) + + let outcome = LaunchOrchestration.run(launch, consumer: dependencies()) + + XCTAssertNil(outcome) + XCTAssertEqual(invocations.map(\.[0]), ["list"], "only the ordinary reap listed; the consumer never ran") + XCTAssertTrue(context.suppressedLaunchPayloads.isEmpty) + XCTAssertFalse(FileManager.default.fileExists(atPath: stateDir.appendingPathComponent(LiveReset.markerFilename).path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: stateDir.appendingPathComponent(LiveReset.consumedFilename).path)) + } + + func testOrderingInventoryThenConsumerThenReap() throws { + let seeded = try addSession() + library.saveAllOpen() + library.saveIndex() + let context = context! + library = WindowLibrary(directory: stateDir, paneFinalizer: nil, launchInventorySink: { context.launchInventory = $0 }) + let session = try XCTUnwrap(library.activeStore?.workspaces.flatMap(\.sessions).first { $0.paneIdentity == seeded.paneIdentity }) + rows = [row(session, leader: 10)] + try store.write(LiveReset.Marker(targets: [target(session, leader: 10)])) + let client = makeClient() + let resolver = ZmxForegroundResolver(leaderProvider: { _ in [:] }, leaderProbe: { .foreground($0) }) + let decision = RestoreLaunchDecision(requested: .live, active: .live, liveUnavailableReason: nil) + + let launch = LaunchOrchestration.Inputs(library: library, client: client, resolver: resolver, context: context, + launchDecision: decision) + let outcome = LaunchOrchestration.run(launch, consumer: dependencies()) + + XCTAssertNotNil(context.launchInventory, "the library handed its inventory to the context before anything ran") + XCTAssertEqual(outcome?.panes.killed, 1) + XCTAssertEqual(invocations.map(\.[0]), ["list", "kill", "list"], "consumer listing and kill, then the ordinary reap's listing") + XCTAssertEqual(context.runningNames, [ZmxSupport.daemonName(for: session.paneIdentity)]) + } +} diff --git a/agtermTests/LiveResetCoordinatorTests.swift b/agtermTests/LiveResetCoordinatorTests.swift new file mode 100644 index 000000000..39cd06cab --- /dev/null +++ b/agtermTests/LiveResetCoordinatorTests.swift @@ -0,0 +1,101 @@ +import XCTest +@testable import agterm +import agtermCore + +@MainActor +final class LiveResetCoordinatorTests: XCTestCase { + private var stateDir: URL! + private var library: WindowLibrary! + private var settingsModel: SettingsModel! + + override func setUp() async throws { + try await super.setUp() + await MainActor.run { + stateDir = FileManager.default.temporaryDirectory + .appendingPathComponent("agterm-live-reset-coordinator-\(UUID().uuidString)", isDirectory: true) + library = WindowLibrary(directory: stateDir) + settingsModel = SettingsModel(library: library, settingsStore: SettingsStore(directory: stateDir)) + XCTAssertTrue(settingsModel.setRestoreMode(.live)) + } + } + + override func tearDown() async throws { + await MainActor.run { + settingsModel = nil + library = nil + try? FileManager.default.removeItem(at: stateDir) + } + try await super.tearDown() + } + + private static let selection = LiveReset.Selection( + targets: [LiveReset.Target(paneIdentity: UUID(), sessionID: UUID(), daemon: "agterm-a", leaderPID: 10)], + inventoryComplete: true) + + private final class Log { + var refusals: [LiveResetCoordinator.Refusal] = [] + var terminations = 0 + var confirmations: [Int] = [] + } + + private func makeCoordinator(selection: LiveReset.Selection?, answer: Bool = true) -> (LiveResetCoordinator, Log) { + let log = Log() + let coordinator = LiveResetCoordinator(settingsModel: settingsModel, selection: { selection }, + activeMode: { .live }, terminate: { log.terminations += 1 }) + coordinator.confirm = { log.confirmations.append($0); return answer } + coordinator.presentRefusal = { log.refusals.append($0) } + return (coordinator, log) + } + + func testMenuRefusalIsPresented() { + let (listingFailed, log1) = makeCoordinator(selection: nil) + listingFailed.runFromMenu() + XCTAssertEqual(log1.refusals, [.listingFailed]) + XCTAssertEqual(log1.terminations, 0) + + let (nothing, log2) = makeCoordinator(selection: LiveReset.Selection(targets: [], inventoryComplete: true)) + nothing.runFromMenu() + XCTAssertEqual(log2.refusals, [.nothingToReset]) + XCTAssertTrue(log2.confirmations.isEmpty, "a refusal never reaches the dialog") + } + + func testMenuCancelIsSilent() { + let (coordinator, log) = makeCoordinator(selection: Self.selection, answer: false) + + coordinator.runFromMenu() + + XCTAssertEqual(log.confirmations, [1]) + XCTAssertTrue(log.refusals.isEmpty) + XCTAssertEqual(log.terminations, 0) + XCTAssertNil(coordinator.pending) + } + + func testMenuConfirmTerminates() { + let (coordinator, log) = makeCoordinator(selection: Self.selection) + + coordinator.runFromMenu() + + XCTAssertEqual(log.confirmations, [1]) + XCTAssertEqual(log.terminations, 1) + XCTAssertEqual(coordinator.armablePending, Self.selection) + } + + func testPendingIsNotArmableAfterAModeChange() { + let (coordinator, log) = makeCoordinator(selection: Self.selection) + XCTAssertEqual(coordinator.request(confirmed: true), .confirmed(Self.selection)) + + XCTAssertTrue(settingsModel.setRestoreMode(.rerun)) + + XCTAssertNotNil(coordinator.pending) + XCTAssertNil(coordinator.armablePending, "a reset confirmed before a mode change must not arm") + coordinator.terminateIfPending() + XCTAssertEqual(log.terminations, 0) + } + + func testUserMessagesNameNoInternals() { + for refusal in [LiveResetCoordinator.Refusal.notLive, .listingFailed, .inventoryIncomplete, .nothingToReset] { + let text = refusal.userMessage.lowercased() + XCTAssertFalse(text.contains("zmx") || text.contains("daemon") || text.contains("attribut"), refusal.userMessage) + } + } +} diff --git a/agtermTests/LiveResetQuitTests.swift b/agtermTests/LiveResetQuitTests.swift new file mode 100644 index 000000000..61b5a0c1f --- /dev/null +++ b/agtermTests/LiveResetQuitTests.swift @@ -0,0 +1,70 @@ +import XCTest +@testable import agterm +import agtermCore + +@MainActor +final class LiveResetQuitTests: XCTestCase { + private static let selection = LiveReset.Selection( + targets: [LiveReset.Target(paneIdentity: UUID(), sessionID: UUID(), daemon: "agterm-a", leaderPID: 10)], + inventoryComplete: true) + + private func flush(pending: LiveReset.Selection?, saveChecked: Bool = true, arm: Bool = true) -> (armed: Bool, order: [String]) { + var order: [String] = [] + let armed = AppDelegate.exitFlush(pending: pending, steps: AppDelegate.ExitFlushSteps( + capture: { order.append("capture") }, + finalize: { order.append("finalize") }, + saveChecked: { order.append("saveChecked"); return saveChecked }, + save: { order.append("save") }, + arm: { _ in order.append("arm"); return arm })) + return (armed, order) + } + + func testCaptureRunsBeforeCheckedSave() { + let result = flush(pending: Self.selection) + XCTAssertTrue(result.armed) + XCTAssertEqual(result.order, ["capture", "finalize", "saveChecked", "arm"]) + } + + func testNoMarkerWhenSaveFails() { + let result = flush(pending: Self.selection, saveChecked: false) + XCTAssertFalse(result.armed) + XCTAssertEqual(result.order, ["capture", "finalize", "saveChecked"]) + } + + func testOrdinaryQuitWritesNothing() { + let result = flush(pending: nil) + XCTAssertFalse(result.armed) + XCTAssertEqual(result.order, ["capture", "finalize", "save"]) + } + + private func makeStore() throws -> (store: LiveResetMarkerStore, marker: URL, dir: URL) { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("agterm-live-reset-quit-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return (LiveResetMarkerStore(directory: dir), dir.appendingPathComponent(LiveReset.markerFilename), dir) + } + + func testMarkerWrittenOnlyAfterCheckedSave() throws { + let fixture = try makeStore() + defer { try? FileManager.default.removeItem(at: fixture.dir) } + + XCTAssertTrue(AppDelegate.armLiveReset(Self.selection, store: fixture.store) { true }) + + XCTAssertTrue(FileManager.default.fileExists(atPath: fixture.marker.path)) + XCTAssertEqual(try fixture.store.consume()?.targets, Self.selection.targets) + } + + func testMarkerRemovedWhenSpawnerFails() throws { + let fixture = try makeStore() + defer { try? FileManager.default.removeItem(at: fixture.dir) } + var sawMarkerWhileSpawning = false + + let armed = AppDelegate.armLiveReset(Self.selection, store: fixture.store) { + sawMarkerWhileSpawning = FileManager.default.fileExists(atPath: fixture.marker.path) + return false + } + + XCTAssertFalse(armed) + XCTAssertTrue(sawMarkerWhileSpawning) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.marker.path)) + } +} diff --git a/agtermTests/LiveResetRelauncherTests.swift b/agtermTests/LiveResetRelauncherTests.swift new file mode 100644 index 000000000..935a1710b --- /dev/null +++ b/agtermTests/LiveResetRelauncherTests.swift @@ -0,0 +1,83 @@ +import XCTest +@testable import agterm + +@MainActor +final class LiveResetRelauncherTests: XCTestCase { + private var dir: URL! + private var record: URL! + private var fakeOpen: URL! + private var oldApp: Process? + + override func setUp() async throws { + try await super.setUp() + dir = FileManager.default.temporaryDirectory.appendingPathComponent("agterm-relauncher-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + record = dir.appendingPathComponent("record") + fakeOpen = dir.appendingPathComponent("fake-open.sh") + try "#!/bin/sh\nprintf '%s\\n' \"$@\" > '\(record.path)'\n".write(to: fakeOpen, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fakeOpen.path) + } + + override func tearDown() async throws { + if let oldApp, oldApp.isRunning { oldApp.terminate() } + try? FileManager.default.removeItem(at: dir) + try await super.tearDown() + } + + private func startOldApp(seconds: String) throws -> pid_t { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sleep") + process.arguments = [seconds] + try process.run() + oldApp = process + return process.processIdentifier + } + + private func recordedArguments(within seconds: TimeInterval) -> [String]? { + let deadline = Date().addingTimeInterval(seconds) + while Date() < deadline { + if let text = try? String(contentsOf: record, encoding: .utf8), !text.isEmpty { + return text.split(separator: "\n").map(String.init) + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return nil + } + + func testLaunchWaitsForChildExit() throws { + let pid = try startOldApp(seconds: "1") + let relauncher = LiveResetRelauncher(open: fakeOpen.path) + + XCTAssertTrue(relauncher.spawn(pid: pid, bundle: URL(fileURLWithPath: "/Applications/Agterm.app"), stateDirectory: "/tmp/state")) + + XCTAssertNil(recordedArguments(within: 0.3), "the launch must wait for the old pid to exit") + oldApp?.waitUntilExit() + XCTAssertEqual(recordedArguments(within: 3), ["-n", "/Applications/Agterm.app", "--env", "AGTERM_STATE_DIR=/tmp/state"]) + } + + func testLaunchWithoutAStateDirectoryPassesNoEnv() throws { + let pid = try startOldApp(seconds: "0.2") + let relauncher = LiveResetRelauncher(open: fakeOpen.path) + + XCTAssertTrue(relauncher.spawn(pid: pid, bundle: URL(fileURLWithPath: "/Applications/Agterm.app"), stateDirectory: nil)) + + XCTAssertEqual(recordedArguments(within: 3), ["-n", "/Applications/Agterm.app"]) + } + + func testNoLaunchOnTimeout() throws { + let pid = try startOldApp(seconds: "5") + let relauncher = LiveResetRelauncher(open: fakeOpen.path, maxWaits: 2) + + XCTAssertTrue(relauncher.spawn(pid: pid, bundle: URL(fileURLWithPath: "/Applications/Agterm.app"), stateDirectory: nil)) + + XCTAssertNil(recordedArguments(within: 1.5)) + oldApp?.terminate() + oldApp?.waitUntilExit() + XCTAssertNil(recordedArguments(within: 1), "a waiter that gave up must not launch once the pid finally exits") + } + + func testSpawnFailureIsReported() { + let relauncher = LiveResetRelauncher(shell: dir.appendingPathComponent("missing-shell").path, open: fakeOpen.path) + XCTAssertFalse(relauncher.spawn(pid: getpid(), bundle: URL(fileURLWithPath: "/Applications/Agterm.app"), stateDirectory: nil)) + } +} diff --git a/agtermTests/ZmxClientLiveResetTests.swift b/agtermTests/ZmxClientLiveResetTests.swift new file mode 100644 index 000000000..ae7b400be --- /dev/null +++ b/agtermTests/ZmxClientLiveResetTests.swift @@ -0,0 +1,65 @@ +import XCTest +@testable import agterm +import agtermCore + +@MainActor +final class ZmxClientLiveResetTests: XCTestCase { + private func client(_ runner: @escaping ZmxClient.Runner) -> ZmxClient { + ZmxClient(executablePath: "/tmp/zmx", socketDirectory: "/tmp/zmx-dir", runner: runner) + } + + func testSessionRecordsKeepUnreadableRows() throws { + let records = client { _ in "name=agterm-a\tpid=11\tclients=0\nname=agterm-b\terr=unreachable" }.sessionRecords() + + let rows = try XCTUnwrap(records) + XCTAssertEqual(rows.map(\.name), ["agterm-a", "agterm-b"]) + XCTAssertEqual(rows[0].leaderPID, 11) + XCTAssertNil(rows[1].clients) + XCTAssertNil(rows[1].leaderPID) + } + + func testSessionRecordsNilOnFailedListing() { + XCTAssertNil(client { _ in throw ZmxClient.CommandError.timedOut }.sessionRecords()) + } + + func testKillBatchSendsOneInvocationUnderTimeout() { + var invocations: [ZmxClient.Invocation] = [] + let client = client { invocation in invocations.append(invocation); return "" } + + XCTAssertTrue(client.killBatch(names: ["agterm-a", "agterm-b", "agterm-a"], timeout: 5)) + + XCTAssertEqual(invocations.count, 1) + XCTAssertEqual(invocations.first?.arguments, ["kill", "agterm-a", "agterm-b", "--force"]) + XCTAssertEqual(invocations.first?.timeout, 5) + } + + func testKillBatchReportsFailure() { + XCTAssertFalse(client { _ in throw ZmxClient.CommandError.failed(1, "boom") }.killBatch(names: ["agterm-a"], timeout: 5)) + } + + func testLeadersExitedReturnsEmptyWhenAllExit() { + var clock = ContinuousClock.Instant.now + var polls = 0 + let poll = ZmxClient.LeaderPoll(now: { clock }, sleep: { clock = clock.advanced(by: $0) }) + + let survivors = ZmxClient.leadersExited([10, 20], deadline: clock.advanced(by: .seconds(10)), poll: poll) { _ in + polls += 1 + return polls < 4 + } + + XCTAssertEqual(survivors, []) + XCTAssertLessThan(clock, ContinuousClock.Instant.now.advanced(by: .seconds(10))) + } + + func testLeadersExitedReturnsSurvivorsAtDeadline() { + var clock = ContinuousClock.Instant.now + var slept: Duration = .zero + let poll = ZmxClient.LeaderPoll(now: { clock }, sleep: { clock = clock.advanced(by: $0); slept += $0 }) + + let survivors = ZmxClient.leadersExited([10, 20], deadline: clock.advanced(by: .seconds(1)), poll: poll) { $0 == 20 } + + XCTAssertEqual(survivors, [20]) + XCTAssertGreaterThanOrEqual(slept, .seconds(1)) + XCTAssertLessThan(slept, .seconds(2)) + } +} diff --git a/docs/plans/completed/20260910-reset-live-sessions.md b/docs/plans/completed/20260910-reset-live-sessions.md new file mode 100644 index 000000000..1ed18f880 --- /dev/null +++ b/docs/plans/completed/20260910-reset-live-sessions.md @@ -0,0 +1,552 @@ +# Reset Live Sessions + +## Overview + +Panes created before v0.28.0, or without the session host, lose their macOS responsible-process +attribution when the agterm that created them exits. Every process inside such a pane becomes its own +responsible process, so a TCC-gated request is charged to that process: each new Claude Code version asks +for the microphone again and adds a per-path row to the Microphone list. Responsibility is decided at +spawn and cannot be reassigned (`responsibility_set_pid_responsible_for_pid` is EPERM; a re-exec keeps the +existing children self-responsible, measured 2026-09-10), so the only repair is a new process under the +host. Today that means recreating each pane by hand, or a Fresh shells relaunch followed by a switch back +to Live and a second relaunch. + +This plan adds one user-facing action, Help ▸ Reset Live Sessions…, and its control command `zmx.reset`. +The action shows how many live sessions it resets and that agterm quits and reopens itself; on confirm the +app quits cleanly, ends only the confirmed daemons at the next launch, and the existing Live restore +recreates those panes through the session host with their quit-captured commands started again. Nothing +is automatic: no upgrade notice, no launch-time detection, only this dialog or an explicit `--force` +control request. + +Measured on the maintainer's Mac on 2026-09-10: 95 daemons, 93 leaders resolve to themselves, 2 resolve to +the host started on 2026-09-09; the tree reads them correctly. That Mac is the sizing case: one reset +covers about 93 panes. + +## Context (from discovery) + +- `agterm/agtermApp+Menus.swift:400` Help group holds the installers; the new item joins it. +- `agterm/AppDelegate.swift:303` quit confirmation alert; `:317` `applicationWillTerminate` runs + `captureOnExit`, `finalizeAllPendingCloses`, unchecked `saveAllOpen`, `saveIndex`. + `WindowLibrary.saveAllOpenChecked()` (`WindowLibrary.swift:556`) reports snapshot write success. + `ExitCapture` is `([Session]) -> Int` (`AppDelegate.swift:6`): a count of slots written, best effort + under a 500 ms budget; zero is not failure. +- `GhosttyApp.capturesForegroundOnExit` includes Live; `AppDelegate.makeExitCapture` reads the CONFIGURED + mode, not the launch latch (`GhosttyApp.swift:65-73`). +- `agterm/agtermApp.swift:272-300` `restoredRuntime` runs from `agtermApp.init()` on the main thread + before any scene body, is skipped entirely under `isHostedUnitTest`, builds `ZmxClient` (`@MainActor`, + synchronous invocations, 3 s default timeout, `captureInvocationTimeout = 0.1`) and passes a + `launchInventorySink` that `WindowLibrary.init` invokes INSIDE the initializer + (`WindowLibrary.swift:144-145`); that closure reaps and then calls + `foregroundResolver.noteLifecycleChange()`. `ZmxReapPolicy.namesToKill` (`ZmxLifecycle.swift:101`) + kills only zero-client daemons and, in Live, only unclaimed ones, in ONE `zmx kill … --force` invocation. +- `ZmxClient.killConfirmed(name:)` classifies zmx's exact output; a zero exit is not a kill. zmx prints + `killed session` when connections close, before its own 500 ms HUP/KILL cleanup finishes, and may + unlink the socket while the leader still runs, so leader exit is the only real confirmation. + `ZmxClient.sessionLeaderPIDs` returns nil on a failed listing and `ZmxLeaderMap.leaders` drops rows + without a leader pid, so a name-to-pid map cannot tell absent from unreadable; `ZmxListParser.parse` + records keep that distinction. +- `SessionHost.classify(leader:responsible:hostPid:appPid:)` in agtermCore is the attribution classifier. + `LiveAttributionProbe` (`ControlServer+Zmx.swift:359`, internal, visible across the app target) + supplies responsible/host/app pids. The tree wrapper enumerates only realized `backedByZmx` surfaces + and cannot serve pre-restore discovery or closed windows. +- `WindowLibrary.paneClaims()` (`WindowLibrary.swift:816`) walks open and saved window claims, including + split panes, each carrying `sessionID`, and reports `complete`. +- Control: `ControlProtocol.swift` commands `zmx.list/prune/kill`, `force` arg; dispatch in + `ControlDispatcher+Zmx.swift`; actions in `ControlDispatcher.swift:150-155` (file at 978 lines against + the 1000-line lint limit), defaults in `ControlActionsDefaults.swift`, app implementation in + `ControlServer+Zmx.swift`; `ControlTree` in `ControlProjection.swift:351`. CLI in + `agtermctlKit/ZmxCommands.swift`. `restoreStatus()` carries configured/requestedAtLaunch/active. + `ControlServer.swift:417-419`: `handleConnection` runs the dispatch on the main actor through + `runBlocking`, then the static `writeResponse` writes the reply on the accept thread with no + per-request state, so a termination scheduled during dispatch can run before the reply is written. +- Notifications: `NotificationManager.start()` runs in the window task (`agtermApp.swift:210`), after + `restoredRuntime`; keymap and config diagnostics recorded at boot are posted there behind the + `hasReopened` gate (`agtermApp.swift:214-224`). +- Live replay: `ZmxLaunch.surfaceSeed` (`ZmxLaunch.swift:100-115`) takes the pending replay and, when + nil, falls back to `initialCommand`/`splitInitialCommand`; a missing daemon runs the payload, a + surviving one ignores it and reattaches. Replay is consumed only at `provider.resolve` during the real + spawn. `ZmxReplayScript.render` adds no resume flags and the denylist still applies. +- `/usr/bin/open` on this Mac documents `--env VAR=value`. +- Test frameworks: `agtermCoreTests` and `agtermctlKitTests` are swift-testing (`@Test func + descriptiveName()`, `#expect`); `agtermTests` is XCTest (`testXxx`). Zmx CLI coverage lives in + `ZmxCommandsTests.swift`. +- Docs listing zmx commands: `plugins/agterm/skills/agterm/{SKILL,reference,examples,troubleshooting}.md`, + `site/commands.html`, `docs/troubleshooting.md:270-289`, `.claude/rules/control-api.md` zmx section, + `.claude/rules/windows.md` quit section. +- Chat review with codex and a plan-review pass settled: marker armed only after capture ran and the + checked save succeeded; explicit selection by name regardless of client count; one batched kill and a + group leader poll under one budget; launch narrows the confirmed set and never widens it; an + unconfirmed pane starts neither its replay nor its durable command; a partial reset is reported; item + shown only when configured AND launched mode are both Live (Eugene's choice: hidden, not disabled); + replay promises no agent resume. + +## Development Approach + +- **testing approach**: TDD. Each task writes the failing test first, runs it, then implements. +- complete each task fully before moving to the next +- make small, focused changes +- **CRITICAL: every task MUST include new/updated tests** for code changes in that task + - tests are not optional; a new function gets a test, a new branch gets a case + - cover success and error scenarios +- **CRITICAL: all tests must pass before starting next task** +- **CRITICAL: update this plan file when scope changes during implementation** +- gates run ONCE at the end (task 7): build, `cd agtermCore && swift test`, `make test-app`, `make lint`; + every intermediate run names the tests that task created, with `--filter` or + `-only-testing://` per method +- host-free logic (marker codec and store, selection, narrowing, outcome shaping, protocol, CLI, text) + lives in `agtermCore`; the app target holds the alert, quit path, waiter spawn and zmx invocations +- no `public` symbol without a caller outside its module; `agtermCore` is consumed by `agterm-linux`, so + new public surface is minimal and Darwin-free +- no sleeps in unit tests: the consumer is generic over its clock and takes an injected liveness check; + real waits use disposable child processes +- no comments that narrate code; the sketches below carry none + +## Testing Strategy + +- **unit tests**: `agtermCoreTests` for `LiveReset` (selection, narrowing, marker store, text, menu + predicate), `ControlDispatcherZmxTests` for `zmx.reset`, `ControlProtocolTests` for payload round trips, + `agtermctlKitTests/ZmxCommandsTests` for the CLI +- **hosted tests** (`make test-app`, `agtermTests`): the control action gating and acknowledgement written + before termination, the quit ordering, the relauncher with a disposable child, the launch orchestration + through an injected seam, and the seed suppression +- **no XCUITest**: the alert is chrome, the command quits the app, and the Help item's visibility predicate + is host-free; the exemption is recorded in `control-api.md` in task 8 +- manual verification on an isolated Debug instance is Task 7; the deployed reset is Post-Completion + +## Progress Tracking + +- mark completed items with `[x]` immediately when done +- add newly discovered tasks with ➕ prefix +- document issues/blockers with ⚠️ prefix + +## Solution Overview + +Three moments, one selection that only narrows: + +1. **Confirm (running app).** The action builds the reset set: every pane claim from + `WindowLibrary.paneClaims()` whose daemon leader classifies as `orphaned` or `app`. `unknown` and + `supervisor` are excluded. `app` is included because this quit turns it into `orphaned`. The dialog + counts SESSIONS (distinct `sessionID` among the targets) and says agterm quits and reopens. On confirm + the pane targets are held in memory, the quit alert is bypassed, and the app terminates normally. +2. **Quit (`applicationWillTerminate`).** The existing best-effort capture runs, then the CHECKED snapshot + save. Capture is invoked, not judged: its count is not a success signal. Only when the checked save + returns true is the marker written atomically to `/live-reset.json` with the confirmed + targets (pane identity, session id, daemon name, observed leader pid). Then the detached waiter is + spawned. If the waiter cannot be spawned the marker is removed and the quit proceeds as an ordinary + quit. +3. **Launch (`restoredRuntime`, after the library is built, before reap and any surface).** The marker is + consumed (renamed away) BEFORE the first kill, so a crash mid-reset never repeats it. The consumer + lists daemons fresh, joins the marker targets against current pane claims, and keeps only targets that + are still claimed, still listed with the SAME leader pid, and still classify as `orphaned`. It sends + ONE batched `zmx kill --force` for exactly those, then polls the leaders as a group until each + exits, all under one 15 s operation budget. A target no longer listed is `gone` and restores normally; + one no longer claimed, with a changed leader, unreadable, or no longer orphaned is `skipped`. The + batch's exit status is not consulted: zmx processes names sequentially, so a failed or timed-out + invocation may already have reached some daemons, and every selected leader is polled regardless. A + leader still alive at the deadline is `unconfirmed`. Unconfirmed panes get a + transient suppression for this launch, keyed by pane identity on `LaunchSpawnContext`, that makes + `surfaceSeed` attach with NEITHER the captured replay NOR the durable + `initialCommand`/`splitInitialCommand`, without deleting either from the model: the attach reconnects + to the old process if its endpoint is still reachable, yields a plain shell only when it is gone, and + in neither case starts a second copy of the command. The ordinary reap and restore then run, and the + consumed marker file is deleted once the outcome is recorded. The outcome is recorded on + `GhosttyApp.shared`, logged, exposed on the tree, and posted as one notification from the window task + once `NotificationManager` has started, behind the same `hasReopened` gate as the diagnostics. The + notification counts SESSIONS, derived from the targets' `sessionID`: a session is reset only when every + one of its targets was killed and confirmed or was gone; a session with any `skipped` or + `unconfirmed` target is partially reset. A partial reset is never silent. + +Design decisions: + +- **Kill at launch, not at quit.** The exiting app is still attached to the daemons and the replay is + only safe once the checked snapshot exists. Launch already owns the reap seam; the app-side closure + stops reaping inside `WindowLibrary.init` and reaps after the consumer, so claims exist before the + reset runs. Replay is consumed only at `provider.resolve` during the real spawn, so a consumer that + runs before `restoredRuntime` returns is always ahead of it. +- **One batched kill, then leader liveness.** Per-name `killConfirmed` would cost one subprocess per + target on a cold launch with no window on screen, about 93 on the sizing case. The existing reap + already kills in one invocation; exit status and per-name output are not the oracle, leader exit is. + The batch invocation runs under a 5 s timeout; leader polling takes the rest of the budget and runs + whatever the invocation returned, because a sequential client that fails midway has already killed + some names and possibly unlinked their sockets, and restoring those panes normally would start the + duplicate command the suppression exists to prevent. +- **Reply before quit is tied to the reset reply.** The connection thread quits only after it has + written the reply to a successful `zmx.reset` request, decided from that request and that response, + never from a shared flag: remote workers write other replies in parallel and an unrelated completion + must not quit the app while the reset reply is still held. +- **Marker carries targets, not a flag.** A boolean would re-select at launch, when every `app` pane and + any pane whose host died has become orphaned, killing panes the dialog never counted. Launch narrows. +- **Detached shell waiter, standard launch.** `NSWorkspace` after termination cannot run in the exiting + process, and opening before exit reaches the running instance. The waiter gets pid, bundle path and + the state directory as positional arguments, never interpolated into shell text, streams to + `/dev/null`, polls `kill -0` in 0.2 s steps for at most 60 s, then runs + `/usr/bin/open -n --env AGTERM_STATE_DIR=` (the `--env` only when the variable is set). + It exits without launching when the old pid never goes away. +- **Reply before quit.** The action sets a main-actor flag on `ControlServer`; `handleConnection` checks + it only after `writeResponse` reports the full frame written, then hops to the main actor without + blocking the accept thread and calls `NSApp.terminate`. A held or failed write never quits. +- **Gate on both modes.** Item and command require `configured == .live && active == .live`; a pending + switch away from Live would clear captures at quit and relaunch non-Live. +- **Wording.** No zmx, daemon or attribution words in the menu, dialog, or notification. + +## Technical Details + +`agtermCore/Sources/agtermCore/LiveReset.swift` (new, host-free): + +```swift +public enum LiveReset { + public struct Target: Codable, Hashable, Sendable { + public let paneIdentity: UUID + public let sessionID: UUID + public let daemon: String + public let leaderPID: Int32 + } + public struct Marker: Codable, Equatable, Sendable { + public static let currentVersion = 1 + public let version: Int + public let createdAt: Date + public let targets: [Target] + } + public struct Selection: Equatable, Sendable { + public let targets: [Target] + public let inventoryComplete: Bool + public var sessionCount: Int { get } + } + public static func select(claims: ZmxClaimWalk, records: [ZmxSessionRecord], + classify: (String, Int32) -> SessionHost.Attribution) -> Selection + public enum Disposition: String, Codable, Equatable, Sendable { + case kill, gone, skipped + } + public static func narrow(marker: Marker, claimed: Set?, records: [ZmxSessionRecord]?, + classify: (String, Int32) -> SessionHost.Attribution) -> Narrowed + public struct Narrowed: Equatable, Sendable { + public let dispositions: [Target: Disposition] + public let inventoryFailed: Bool + public var kill: [Target] { get } + } + public struct Outcome: Codable, Equatable, Sendable { + public let panes: PaneCounts // confirmed, killed, gone, skipped + public let unconfirmed: [UUID] // pane identities to suppress + public let sessions: SessionCounts // affected, reset, partial (distinct sessionID) + public let inventoryFailed: Bool + } + public static func outcome(narrowed: Narrowed, survivors: Set, inventoryFailed: Bool) -> Outcome + public static let markerFilename = "live-reset.json" + public static func dialogText(sessionCount: Int) -> (title: String, body: String) + public static func notificationText(outcome: Outcome) -> String? + public static func menuVisible(configured: RestoreMode, active: RestoreMode) -> Bool +} + +public struct LiveResetMarkerStore { + public init(directory: URL) + public func write(_ marker: LiveReset.Marker) throws + public func consume() throws -> LiveReset.Marker? + public func removeConsumed() + public func remove() +} +``` + +`write` is temp file plus rename. `consume` renames the marker to `live-reset.consumed.json` and decodes +it; a missing file is nil; an undecodable file or a version other than `currentVersion` is removed and +reported as `.invalid`; a rename failure is thrown and the caller must not kill anything. `removeConsumed` +deletes the consumed file after the outcome is recorded; `remove` deletes both on the quit-failure path. +The version field guards a hand-edited or interrupted-upgrade marker at the cost of two lines. + +Dialog text, one source for the alert and the control acknowledgement: + +- title: `Reset Live Sessions?` +- body: `N live sessions will be reset. Agterm quits and reopens itself right away with your sessions + and layout. Commands that were running in those sessions are started again where possible; other work + running in them stops, and agent conversations may need to be resumed by hand.` +- buttons: `Cancel` (default), `Reset` + +Notification text, nil only when `sessions.partial == 0` and the inventory did not fail. Counts are +sessions; a session with one confirmed and one unconfirmed pane is one partial session: + +- `The reset covered M of N live sessions. Run Help ▸ Reset Live Sessions… again for the rest.` followed, + when `unconfirmed` is non-empty, by `Previous processes in K sessions may still be running, and commands + in those sessions were not restarted.` +- inventory failed: `Live sessions were not reset: the session list could not be read.` + +Control: + +- `ControlCommand.zmxReset = "zmx.reset"`, app-global, no target. The dispatcher refuses without + `--force` by name, exactly like `zmx.kill`, and never reaches the action. The app action then refuses, + in order: not Live in both modes; inventory incomplete; zero targets. +- Response `result.liveReset: ControlLiveResetStatus { sessions: Int, panes: Int, pending: Bool }` plus + `result.text` from `dialogText`. The reply is written before termination is requested. +- Read-back: `ControlTree` and the `zmx list` header gain `liveReset: ControlLiveResetReadback { pending: + Int?, last: LiveReset.Outcome? }`, both omitted when nil. `pending` is the in-memory confirmed pane + count from confirmation until quit; `last` is the outcome of the launch that consumed a marker. +- Events: none added; the action is a quit and the existing lifecycle events cover it. +- XCUITest exemption: `zmx.reset` quits the app, so it has no end-to-end UI test; recorded in + `control-api.md` beside the catalog entry, as for `restore.mode` and `surface.cursor`. + +App target: + +- `ZmxClient.sessionRecords(timeout:) -> [ZmxSessionRecord]?` beside `sessionLeaderPIDs`, returning the + parsed rows so absent and unreadable stay distinct. `ZmxClient.killBatch(names: [String], timeout:) + -> Bool` is the existing private `kill(names:)` with an explicit timeout, exposed to the consumer. + `ZmxClient.leadersExited(_ pids: Set, deadline: C.Instant, clock: C, isAlive: (pid_t) + -> Bool) -> Set` where `C.Duration == Duration` polls the group every 100 ms and returns the pids + still alive at the deadline. +- `AppActions+LiveReset.swift`: `resetLiveSessions(confirmed: Bool) -> LiveResetRequestOutcome` builds the + selection from `library.paneClaims()` and `zmxClient.sessionRecords()`, refuses per the order above, + shows the alert unless `confirmed`, then stores `pendingLiveReset` on `AppDelegate`, sets + `quitConfirmed`, and calls `NSApp.terminate` through the supplied `terminate` closure. +- `ControlServer`: `writeResponse` returns whether the full frame was written. `handleConnection` decides + from the request and response it holds: when `request.cmd == .zmxReset` and `response.ok`, and only + after `writeResponse` returned true, it schedules `DispatchQueue.main.async { server.terminateForLiveReset() }`, + which terminates when `pendingLiveReset` is still set. No shared flag exists, so a remote worker + finishing an unrelated reply cannot quit the app. On a false return the failure is logged and + `pendingLiveReset` stays set so the menu or a later request can retry. +- `AppDelegate.applicationShouldTerminate` returns `.terminateNow` when `quitConfirmed`. +- `AppDelegate.applicationWillTerminate`: after capture and `finalizeAllPendingCloses`, when a pending + reset exists use `saveAllOpenChecked()`; on `true` write the marker, then + `LiveResetRelauncher.spawn(pid:bundle:stateDirectory:)`; on any failure remove the marker and log. + Without a pending reset the path is unchanged. +- `LiveResetRelauncher` (app target): `Process` running `/bin/sh -c '