Merge nucleic/keen-river-shrew-4cdk into dev
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String> = []
|
||||
|
||||
/// 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<String> {
|
||||
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,
|
||||
|
||||
@@ -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<Int>()
|
||||
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,
|
||||
|
||||
@@ -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, _, _):
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user