Control: route health/heartbeat over RPC stdio

This commit is contained in:
Peter Steinberger
2025-12-09 02:25:01 +00:00
parent 99a3102134
commit 04f595cd97
3 changed files with 239 additions and 440 deletions

View File

@@ -15,12 +15,58 @@ actor AgentRPC {
}
static let heartbeatNotification = Notification.Name("clawdis.rpc.heartbeat")
static let agentEventNotification = Notification.Name("clawdis.rpc.agent")
private struct ControlResponse: Decodable {
let type: String
let id: String
let ok: Bool
let payload: AnyCodable?
let error: String?
}
struct AnyCodable: Codable {
let value: Any
init(_ value: Any) { self.value = value }
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let intVal = try? container.decode(Int.self) { self.value = intVal; return }
if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return }
if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return }
if let stringVal = try? container.decode(String.self) { self.value = stringVal; return }
if container.decodeNil() { self.value = NSNull(); return }
if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return }
if let array = try? container.decode([AnyCodable].self) { self.value = array; return }
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type")
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self.value {
case let intVal as Int: try container.encode(intVal)
case let doubleVal as Double: try container.encode(doubleVal)
case let boolVal as Bool: try container.encode(boolVal)
case let stringVal as String: try container.encode(stringVal)
case is NSNull: try container.encodeNil()
case let dict as [String: AnyCodable]: try container.encode(dict)
case let array as [AnyCodable]: try container.encode(array)
default:
let context = EncodingError.Context(
codingPath: encoder.codingPath,
debugDescription: "Unsupported type")
throw EncodingError.invalidValue(self.value, context)
}
}
}
private var process: Process?
private var stdinHandle: FileHandle?
private var stdoutHandle: FileHandle?
private var buffer = Data()
private var waiters: [CheckedContinuation<String, Error>] = []
private var controlWaiters: [String: CheckedContinuation<Data, Error>] = [:]
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "agent.rpc")
private var starting = false
@@ -127,6 +173,22 @@ actor AgentRPC {
}
}
func controlRequest(method: String, params: [String: Any]? = nil) async throws -> Data {
if self.process?.isRunning != true {
try await self.start()
}
let id = UUID().uuidString
var frame: [String: Any] = ["type": "control-request", "id": id, "method": method]
if let params { frame["params"] = params }
let data = try JSONSerialization.data(withJSONObject: frame)
guard let stdinHandle else { throw RpcError(message: "stdin missing") }
return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Data, Error>) in
self.controlWaiters[id] = cont
stdinHandle.write(data)
stdinHandle.write(Data([0x0A]))
}
}
// MARK: - Process lifecycle
func start() async throws {
@@ -180,6 +242,11 @@ actor AgentRPC {
for waiter in waiters {
waiter.resume(throwing: RpcError(message: "rpc process stopped"))
}
let control = self.controlWaiters
self.controlWaiters.removeAll()
for (_, waiter) in control {
waiter.resume(throwing: RpcError(message: "rpc process stopped"))
}
}
private func ingest(data: Data) {
@@ -189,11 +256,11 @@ actor AgentRPC {
self.buffer.removeSubrange(self.buffer.startIndex...range.lowerBound)
guard let line = String(data: lineData, encoding: .utf8) else { continue }
// Event frames are pushed without request/response pairing (e.g., heartbeats).
if let event = self.parseHeartbeatEvent(from: line) {
DispatchQueue.main.async {
NotificationCenter.default.post(name: Self.heartbeatNotification, object: event)
}
// Event frames are pushed without request/response pairing (e.g., heartbeats/agent).
if self.handleEventLine(line) {
continue
}
if self.handleControlResponse(line) {
continue
}
if let waiter = waiters.first {
@@ -221,6 +288,62 @@ actor AgentRPC {
return try? decoder.decode(HeartbeatEvent.self, from: payloadData)
}
private func parseAgentEvent(from line: String) -> ControlAgentEvent? {
guard let data = line.data(using: .utf8) else { return nil }
guard
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = obj["type"] as? String,
type == "event",
let evt = obj["event"] as? String,
evt == "agent",
let payload = obj["payload"]
else {
return nil
}
guard let payloadData = try? JSONSerialization.data(withJSONObject: payload) else { return nil }
return try? JSONDecoder().decode(ControlAgentEvent.self, from: payloadData)
}
private func handleEventLine(_ line: String) -> Bool {
if let hb = self.parseHeartbeatEvent(from: line) {
DispatchQueue.main.async {
NotificationCenter.default.post(name: Self.heartbeatNotification, object: hb)
NotificationCenter.default.post(name: .controlHeartbeat, object: hb)
}
return true
}
if let agent = self.parseAgentEvent(from: line) {
DispatchQueue.main.async {
NotificationCenter.default.post(name: Self.agentEventNotification, object: agent)
NotificationCenter.default.post(name: .controlAgentEvent, object: agent)
}
return true
}
return false
}
private func handleControlResponse(_ line: String) -> Bool {
guard let data = line.data(using: .utf8) else { return false }
guard let parsed = try? JSONDecoder().decode(ControlResponse.self, from: data) else { return false }
guard parsed.type == "control-response" else { return false }
guard let waiter = self.controlWaiters.removeValue(forKey: parsed.id) else {
self.logger.debug("control response with no waiter id=\(parsed.id, privacy: .public)")
return true
}
if parsed.ok {
let payloadData: Data = if let payload = parsed.payload {
(try? JSONEncoder().encode(payload)) ?? Data()
} else {
Data()
}
waiter.resume(returning: payloadData)
} else {
waiter.resume(throwing: RpcError(message: parsed.error ?? "control error"))
}
return true
}
private func nextLine() async throws -> String {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<String, Error>) in
self.waiters.append(cont)