diff --git a/Sources/NucleicCore/AppStore.swift b/Sources/NucleicCore/AppStore.swift index 3ac509a4..a16a008c 100644 --- a/Sources/NucleicCore/AppStore.swift +++ b/Sources/NucleicCore/AppStore.swift @@ -9582,7 +9582,10 @@ extension AppStore: SyncHostBridge { // Mesh-wide credential deletion (REMOTE_AGENT_LOGIN follow-up): every // AppStore-backed host lands `credentialRevoke` — a runner clears its landed files, // a cockpit Mac its Keychain stores — records the tombstone, and fans it out. - canRevokeCredentials: true) + canRevokeCredentials: true, + // Remote stall resolution: every AppStore-backed host runs the commands whose stalls + // the alert reports, so it can honor a remote Kill / Keep-waiting (`resolveProcessStall`). + canResolveProcessStall: true) } /// Mint a join code for a phone relaying "add a device to this mesh" (`ClientMsg @@ -10733,6 +10736,16 @@ extension AppStore: SyncHostBridge { await controller.interrupt() } + public func resolveProcessStall(_ id: SessionID, stallID: String, kill: Bool) async throws { + guard controllers[id] != nil else { + throw WireError(code: .unknownSession, message: "no such session", sessionID: id) + } + // Reuse the same path the Mac's own transcript buttons take: mark the alert resolved so its + // row retires (locally and for every subscriber, via the resolution note the backend emits) + // and tear down / dismiss on the running controller. + await resolveProcessStall(stallID, kill: kill, inSession: id) + } + public func cancelQueuedMessage(_ id: SessionID, _ messageID: UUID) async throws { guard let controller = controllers[id] else { throw WireError(code: .unknownSession, message: "no such session", sessionID: id) diff --git a/Sources/NucleicCore/Sync/ConnectionHandler.swift b/Sources/NucleicCore/Sync/ConnectionHandler.swift index b3b651d7..91a01341 100644 --- a/Sources/NucleicCore/Sync/ConnectionHandler.swift +++ b/Sources/NucleicCore/Sync/ConnectionHandler.swift @@ -422,6 +422,14 @@ actor ConnectionHandler { case .interrupt(let id): guard requireControl(id) else { return } await guardedCall(sessionID: id) { try await self.bridge.interrupt(id) } + case .resolveProcessStall(let id, let stallID, let kill): + // A remote surface resolved a "host command looks hung" alert — Kill (tear down the + // command's process tree) or Keep-waiting (dismiss). Control scope, matching the Mac's + // own in-transcript buttons. Gated client-side on `canResolveProcessStall`. + guard requireControl(id) else { return } + await guardedCall(sessionID: id) { + try await self.bridge.resolveProcessStall(id, stallID: stallID, kill: kill) + } case .listDashboard: send(.dashboard(await bridge.dashboardSnapshot())) case .listPeers: diff --git a/Sources/NucleicCore/Sync/SyncHostBridge.swift b/Sources/NucleicCore/Sync/SyncHostBridge.swift index c65efa89..b8278a4c 100644 --- a/Sources/NucleicCore/Sync/SyncHostBridge.swift +++ b/Sources/NucleicCore/Sync/SyncHostBridge.swift @@ -42,6 +42,13 @@ public protocol SyncHostBridge: Sendable { /// Cancel one queued (not-yet-sent) follow-up by id (scope ≥ control). func cancelQueuedMessage(_ id: SessionID, _ messageID: UUID) async throws + /// Resolve a live "host command looks hung" alert (`ClientMsg.resolveProcessStall`, scope ≥ + /// control): `kill == true` tears down that command's whole process tree, `false` dismisses the + /// alert and lets it run on. `stallID` is the `ProcessStallNote.id`. Defaulted no-op so bridges + /// that don't run commands keep compiling — they just shouldn't advertise + /// `canResolveProcessStall`. + func resolveProcessStall(_ id: SessionID, stallID: String, kill: Bool) async throws + // Control scope: the same actions the Mac can take (UX_IOS §10, lifted to control). func dashboardSnapshot() async -> DashboardSnapshot func startChat(_ request: StartChatRequest) async throws @@ -281,6 +288,7 @@ public protocol SyncHostBridge: Sendable { extension SyncHostBridge { /// Default host kind: a Mac. A runner host overrides this to `.cloud`. public var hostKind: PeerKind { get async { .mac } } + public func resolveProcessStall(_ id: SessionID, stallID: String, kill: Bool) async throws {} public func sessionDiff(_ id: SessionID) async -> WireSessionDiff? { nil } public func transcriptEvents(_ id: SessionID, afterSeq: UInt64) async -> (header: SessionHeader?, events: [AgentEvent])? { nil } diff --git a/Sources/NucleicProtocol/Sync/MessageEnvelope.swift b/Sources/NucleicProtocol/Sync/MessageEnvelope.swift index fa79f4b6..5b018444 100644 --- a/Sources/NucleicProtocol/Sync/MessageEnvelope.swift +++ b/Sources/NucleicProtocol/Sync/MessageEnvelope.swift @@ -35,6 +35,12 @@ public enum ClientMsg: Sendable, Equatable { /// Cancel one queued (not-yet-sent) follow-up by id — matches the Mac composer's per-message /// ✕. The `UUID` is the `QueuedMessage.id` from the session summary. case cancelQueuedMessage(SessionID, UUID) + /// Resolve a live "host command looks hung" alert from a remote surface (the Mac's + /// `AppStore.resolveProcessStall`, lifted to the wire): `kill == true` tears down that + /// command's whole process tree, `false` dismisses the alert and lets it run on. `stallID` is + /// the `ProcessStallNote.id` from the alert note. `.control` scope. Send only when the host + /// advertised `WireCapabilities.canResolveProcessStall`; an older host throws on the unknown tag. + case resolveProcessStall(SessionID, stallID: String, kill: Bool) /// Ask for the session's full worktree diff (`HostMsg.sessionDiff`) — the Mac Diff tab's /// patch, on demand. Read-only (scope ≥ view). Send only when /// `WireCapabilities.canFetchDiff`; an older host treats it as an undecodable frame. @@ -374,6 +380,8 @@ public enum HostMsg: Sendable, Equatable { private enum EnvelopeKey: String, CodingKey { case t, sessionID, approvalID, decision, input, tag case request, todoID, projectID, todoStatus, title, flag, mode + // Remote resolution of a "host command looks hung" alert (the Kill / Keep-waiting buttons). + case stallID, kill case model, effort, branch, messageID, addresses // Session transfer (mesh P5). case transferID, offer, chunk, accept, reject, ack @@ -487,6 +495,11 @@ extension ClientMsg: Codable { self = .cancelQueuedMessage( try c.decode(SessionID.self, forKey: .sessionID), try c.decode(UUID.self, forKey: .messageID)) + case "resolveProcessStall": + self = .resolveProcessStall( + try c.decode(SessionID.self, forKey: .sessionID), + stallID: try c.decode(String.self, forKey: .stallID), + kill: try c.decode(Bool.self, forKey: .kill)) case "fetchDiff": self = .fetchDiff(try c.decode(SessionID.self, forKey: .sessionID)) case "listPeers": self = .listPeers @@ -638,6 +651,11 @@ extension ClientMsg: Codable { try c.encode("cancelQueuedMessage", forKey: .t) try c.encode(id, forKey: .sessionID) try c.encode(messageID, forKey: .messageID) + case .resolveProcessStall(let id, let stallID, let kill): + try c.encode("resolveProcessStall", forKey: .t) + try c.encode(id, forKey: .sessionID) + try c.encode(stallID, forKey: .stallID) + try c.encode(kill, forKey: .kill) case .fetchDiff(let id): try c.encode("fetchDiff", forKey: .t); try c.encode(id, forKey: .sessionID) case .listPeers: diff --git a/Sources/NucleicProtocol/Sync/WireMessages.swift b/Sources/NucleicProtocol/Sync/WireMessages.swift index 9fa980b2..3e1cde47 100644 --- a/Sources/NucleicProtocol/Sync/WireMessages.swift +++ b/Sources/NucleicProtocol/Sync/WireMessages.swift @@ -240,6 +240,12 @@ public struct WireCapabilities: Sendable, Codable, Equatable { /// re-gossip). A client must not send it unless this is set; an older host throws on the /// unknown tag (same gating contract as `canBrokerAgentLogin`). Omitted ⇒ `false`. public let canRevokeCredentials: Bool + /// Whether this host accepts `ClientMsg.resolveProcessStall` — a remote surface resolving a + /// live "host command looks hung" alert (Kill tears down the process tree, Keep-waiting + /// dismisses it), the wire counterpart of the Mac's in-transcript Kill/Keep-waiting buttons. A + /// client must not send it unless this is set; an older host throws on the unknown tag (same + /// gating contract as `canRevokeCredentials`). Omitted ⇒ `false`. + public let canResolveProcessStall: Bool public init( canModifyToolInput: Bool, allowAlwaysScopes: [AlwaysScope], @@ -255,7 +261,8 @@ public struct WireCapabilities: Sendable, Codable, Equatable { canSyncSettings: Bool = false, canDirectConnect: Bool = false, canBrokerAgentLogin: Bool = false, - canRevokeCredentials: Bool = false + canRevokeCredentials: Bool = false, + canResolveProcessStall: Bool = false ) { self.canModifyToolInput = canModifyToolInput self.allowAlwaysScopes = allowAlwaysScopes @@ -279,6 +286,7 @@ public struct WireCapabilities: Sendable, Codable, Equatable { self.canDirectConnect = canDirectConnect self.canBrokerAgentLogin = canBrokerAgentLogin self.canRevokeCredentials = canRevokeCredentials + self.canResolveProcessStall = canResolveProcessStall } private enum CodingKeys: String, CodingKey { @@ -292,6 +300,7 @@ public struct WireCapabilities: Sendable, Codable, Equatable { case canDirectConnect case canBrokerAgentLogin case canRevokeCredentials + case canResolveProcessStall } public init(from decoder: Decoder) throws { @@ -335,6 +344,8 @@ public struct WireCapabilities: Sendable, Codable, Equatable { try c.decodeIfPresent(Bool.self, forKey: .canBrokerAgentLogin) ?? false self.canRevokeCredentials = try c.decodeIfPresent(Bool.self, forKey: .canRevokeCredentials) ?? false + self.canResolveProcessStall = + try c.decodeIfPresent(Bool.self, forKey: .canResolveProcessStall) ?? false } } diff --git a/Tests/NucleicRemoteProjectionTests/IncrementalProjectionEquivalenceTests.swift b/Tests/NucleicRemoteProjectionTests/IncrementalProjectionEquivalenceTests.swift index a5337c8c..e0027e91 100644 --- a/Tests/NucleicRemoteProjectionTests/IncrementalProjectionEquivalenceTests.swift +++ b/Tests/NucleicRemoteProjectionTests/IncrementalProjectionEquivalenceTests.swift @@ -275,7 +275,7 @@ import NucleicProtocol /// Note texts directly in `items` (not descending into subagent children). private static func noteTexts(_ items: [TranscriptItem]) -> [String] { - items.compactMap { if case .note(let text, _, _, _) = $0.kind { return text } else { return nil } } + items.compactMap { if case .note(let text, _, _, _, _) = $0.kind { return text } else { return nil } } } /// The `children` of the subagent spawn whose tool-call id is `cardID`, wherever it renders diff --git a/ios/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/ios/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index d4eba258..bbec275e 100644 --- a/ios/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/ios/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -378,6 +378,27 @@ final class RemoteStore: ObservableObject { @Published private(set) var openEvents: [AgentEvent] = [] @Published private(set) var openApprovals: [ApprovalRequest] = [] + /// Stall ids the user acted on locally (Kill / Keep-waiting) before the host's authoritative + /// resolution note has arrived — an optimistic set so the alert row retires its buttons + /// immediately. The host's resolution note (carried in `openEvents`) is the durable signal; + /// `resolvedStalls` unions the two. Mirrors the Mac's `AppStore.resolvedStalls`. + @Published private var locallyResolvedStalls: Set = [] + + /// Stall ids whose "host command looks hung" alert has been closed out — resolved locally + /// (optimistic) or by a resolution note (`ProcessStallNote.resolution != nil`) in the open + /// transcript. The transcript's alert row reads this to retire its Kill / Keep-waiting buttons + /// once the alert is done. Scans only the open transcript, and only a (rare) alert row reads it. + var resolvedStalls: Set { + var ids = locallyResolvedStalls + for event in openEvents { + if case .note(let note) = event.kind, let stall = note.processStall, + stall.resolution != nil { + ids.insert(stall.id) + } + } + return ids + } + /// The open session is blocked on an approval whose details haven't arrived yet — the state /// right after opening from a Live Activity or notification while the channel is still /// (re)connecting. The summary says `.awaitingApproval`, but `openApprovals` (which is filled @@ -660,7 +681,7 @@ final class RemoteStore: ObservableObject { case .sendInput, .startChat, .captureTodo, .dispatchTodo, .setTodoStatus, .deleteTodo, .renameSession, .setFavorite, .setArchived, .deleteSession, .discard, .integrate, .interrupt, .cancelQueuedMessage, .setSessionModel, .setSessionEffort, .setSessionAuto, - .setSessionAutoShip, .setSessionShipBranch, .approvalRespond: + .setSessionAutoShip, .setSessionShipBranch, .approvalRespond, .resolveProcessStall: return true default: return false @@ -2399,6 +2420,14 @@ final class RemoteStore: ObservableObject { /// Throw away the session's branch/worktree without landing it (the Mac's Discard…). func discard(_ id: SessionID) { send(.discard(id)) } func interrupt(_ id: SessionID) { send(.interrupt(id)) } + /// Resolve a live "host command looks hung" alert — the transcript's Kill (`kill: true`, tears + /// down the command's process tree) / Keep-waiting (`kill: false`, dismiss) buttons. Optimistically + /// marks the alert resolved so its buttons retire at once; the host then emits the authoritative + /// resolution note. Mirrors the Mac's `AppStore.resolveProcessStall`. + func resolveProcessStall(_ stallID: String, kill: Bool, inSession id: SessionID) { + locallyResolvedStalls.insert(stallID) + send(.resolveProcessStall(id, stallID: stallID, kill: kill)) + } /// Cancel one queued (not-yet-sent) follow-up by id — the phone's per-message ✕. func cancelQueuedMessage(_ id: SessionID, _ messageID: UUID) { send(.cancelQueuedMessage(id, messageID)) } @@ -2525,7 +2554,8 @@ final class RemoteStore: ObservableObject { .integrate(let id, _), .renameSession(let id, _), .setFavorite(let id, _), .setArchived(let id, _), .setSessionModel(let id, _), .setSessionEffort(let id, _), .setSessionAuto(let id, _), .setSessionAutoShip(let id, _), .setSessionShipBranch(let id, _), - .sendInput(let id, _), .cancelQueuedMessage(let id, _), .fetchDiff(let id): + .sendInput(let id, _), .cancelQueuedMessage(let id, _), .fetchDiff(let id), + .resolveProcessStall(let id, _, _): connection(owningSession: id)?.send(msg) // Project-owning intents → the Mac that has this project. @@ -2758,6 +2788,15 @@ final class RemoteStore: ObservableObject { demoUpdateSession(id) { $0.demoCopy(autoShip: autoShip) } case .setSessionShipBranch(let id, let branch): demoUpdateSession(id) { $0.demoCopy(shipBranch: .some(branch)) } + case .resolveProcessStall(let id, let stallID, let kill): + // No real command runs in demo; echo the resolution note so the alert row retires just + // as it would against a live host (its `resolution` unions into `resolvedStalls`). + demoAppend(id, .note(NoteEvent( + text: kill ? "Killed the stalled command." : "Dismissed the stall alert.", + icon: kill ? "xmark.octagon" : "clock", + processStall: ProcessStallNote( + id: stallID, label: "", pidCount: 1, idleSeconds: 0, + resolution: kill ? "killed" : "dismissed")))) case .hello, .listSessions, .listDashboard, .subscribe, .unsubscribe, .ping, .cancelQueuedMessage, .fetchDiff, .fetchTranscript, .listPeers, .addressUpdate, .meshRoster, diff --git a/ios/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift b/ios/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift index f75ebdb6..44232600 100644 --- a/ios/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift +++ b/ios/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift @@ -36,8 +36,12 @@ struct TranscriptItem: Identifiable, Equatable { case error(message: String) /// A passthrough note. `lock` carries the structured lock detail when this is a file-lock /// lifecycle moment, so the projection can fold it onto the edit card it brackets; `nil` - /// for every non-lock note (and lock notes with no paths). - case note(text: String, icon: String?, lockEvent: Bool, lock: NoteLock? = nil) + /// for every non-lock note (and lock notes with no paths). `processStall` carries the + /// "host command looks hung" alert payload when this note is one (or its resolution), so the + /// row can render the amber Kill / Keep-waiting alert (control parity with the Mac); `nil` + /// for every other note. + case note(text: String, icon: String?, lockEvent: Bool, lock: NoteLock? = nil, + processStall: ProcessStallNote? = nil) case raw(type: String, body: String) } @@ -253,7 +257,7 @@ enum TranscriptProjection { var locksByCall: [String: [NoteLock]] = [:] var folded = Set() for (i, item) in flat.enumerated() { - guard case .note(_, _, true, let noteLock) = item.kind, + guard case .note(_, _, true, let noteLock, _) = item.kind, let lock = noteLock, !lock.paths.isEmpty else { continue } // Route each path to the nearest preceding edit card that touches it, so a multi-file // note brackets each file's own card. Fold only when *every* path lands on a card; a @@ -557,7 +561,8 @@ enum TranscriptProjection { if note.lockEvent && !showLockEvents { break } items.append(.init(id: "note-\(event.seq)", seq: event.seq, kind: .note(text: note.text, icon: note.icon, - lockEvent: note.lockEvent, lock: note.lock))) + lockEvent: note.lockEvent, lock: note.lock, + processStall: note.processStall))) case .raw(let raw): guard showRaw else { break } items.append(.init(id: "raw-\(event.seq)", seq: event.seq, diff --git a/ios/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift b/ios/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift index 548b2c8e..1da85a47 100644 --- a/ios/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift +++ b/ios/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift @@ -58,9 +58,16 @@ struct TranscriptRow: View { case .error(let message): Label(message, systemImage: "xmark.octagon.fill") .font(.caption).foregroundStyle(Palette.danger) - case .note(let text, let icon, _, _): - Label(text, systemImage: icon ?? "arrow.triangle.branch") - .font(.caption).foregroundStyle(.secondary) + case .note(let text, let icon, _, _, let processStall): + // A live "host command looks hung" alert reads as a distinct amber row with Kill / + // Keep-waiting buttons (control parity with the Mac's `ProcessStallRow`); once resolved + // — or for any non-stall note — it falls back to the plain secondary label. + if let stall = processStall, stall.resolution == nil { + ProcessStallRow(text: text, stall: stall) + } else { + Label(text, systemImage: icon ?? "arrow.triangle.branch") + .font(.caption).foregroundStyle(.secondary) + } case .raw(let type, let body): Text("[\(type)] \(body)").font(.caption2.monospaced()).foregroundStyle(.secondary).lineLimit(2) } @@ -100,6 +107,56 @@ struct TranscriptRow: View { } } +/// The in-chat rendering for a live "host command looks hung" alert — the mobile echo of the Mac's +/// `ProcessStallRow`, for control parity (UX_IOS §5). The harness raises it when a running +/// `host_exec` command goes silent with near-zero CPU (see `ProcessStallMonitor`). It reads as an +/// amber warning row with **Kill** (tears down the command's whole process tree) and **Keep +/// waiting** (dismiss the alert; the command runs on). Once resolved — killed, dismissed, or the +/// command ended on its own — the buttons give way to a muted "resolved" note. Buttons are hidden +/// when the device lacks control scope or the host can't resolve stalls remotely (older host), +/// degrading to a plain warning line. +struct ProcessStallRow: View { + @EnvironmentObject var store: RemoteStore + let text: String + let stall: ProcessStallNote + + private var resolved: Bool { store.resolvedStalls.contains(stall.id) } + private var canResolve: Bool { + store.canControl && store.capabilities.canResolveProcessStall && store.openSessionID != nil + } + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption).foregroundStyle(Palette.attention).frame(width: 16) + Text(text) + .font(.callout).foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true).textSelection(.enabled) + if resolved { + Text("resolved").font(.caption).foregroundStyle(.secondary) + } else if canResolve, let sessionID = store.openSessionID { + Button { + store.resolveProcessStall(stall.id, kill: true, inSession: sessionID) + } label: { + Label( + stall.pidCount > 1 ? "Kill (\(stall.pidCount) processes)" : "Kill", + systemImage: "xmark.octagon") + .font(.caption.weight(.semibold)) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .tint(Palette.danger) + Button("Keep waiting") { + store.resolveProcessStall(stall.id, kill: false, inSession: sessionID) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + Spacer(minLength: 0) + } + } +} + /// Render an assistant/user message with the full Markdown renderer the Mac transcript uses /// (fenced code blocks, headings, lists, tables, and inline emphasis/links/`code`), preserving /// the line breaks of multi-paragraph replies. Falls back to plain text if parsing fails.