Merge nucleic/keen-river-shrew-4cdk into dev

This commit is contained in:
2026-07-19 03:40:03 -07:00
parent 4c0339f336
commit af24bbc0df
9 changed files with 171 additions and 12 deletions
+14 -1
View File
@@ -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, _, _):
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.