fix: harden heartbeat acks + gateway reconnect
This commit is contained in:
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Package contents: include Discord/hooks build outputs in the npm tarball to avoid missing module errors.
|
- Package contents: include Discord/hooks build outputs in the npm tarball to avoid missing module errors.
|
||||||
|
- Heartbeat replies now drop any output containing `HEARTBEAT_OK`, preventing stray emoji/text from being delivered.
|
||||||
|
- macOS menu now refreshes the control channel after the gateway starts and shows “Connecting to gateway…” while the gateway is coming up.
|
||||||
|
|
||||||
## 2.0.0-beta3 — 2025-12-27
|
## 2.0.0-beta3 — 2025-12-27
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ final class GatewayProcessManager {
|
|||||||
self.existingGatewayDetails = details
|
self.existingGatewayDetails = details
|
||||||
self.status = .attachedExisting(details: details)
|
self.status = .attachedExisting(details: details)
|
||||||
self.appendLog("[gateway] using existing instance: \(details)\n")
|
self.appendLog("[gateway] using existing instance: \(details)\n")
|
||||||
|
self.refreshControlChannelIfNeeded(reason: "attach existing")
|
||||||
self.refreshLog()
|
self.refreshLog()
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
@@ -289,6 +290,7 @@ final class GatewayProcessManager {
|
|||||||
let instance = await PortGuardian.shared.describe(port: port)
|
let instance = await PortGuardian.shared.describe(port: port)
|
||||||
let details = instance.map { "pid \($0.pid)" }
|
let details = instance.map { "pid \($0.pid)" }
|
||||||
self.status = .running(details: details)
|
self.status = .running(details: details)
|
||||||
|
self.refreshControlChannelIfNeeded(reason: "gateway started")
|
||||||
self.refreshLog()
|
self.refreshLog()
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
@@ -307,6 +309,17 @@ final class GatewayProcessManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func refreshControlChannelIfNeeded(reason: String) {
|
||||||
|
switch ControlChannel.shared.state {
|
||||||
|
case .connected, .connecting:
|
||||||
|
return
|
||||||
|
case .disconnected, .degraded:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
self.appendLog("[gateway] refreshing control channel (\(reason))\n")
|
||||||
|
Task { await ControlChannel.shared.configure() }
|
||||||
|
}
|
||||||
|
|
||||||
func clearLog() {
|
func clearLog() {
|
||||||
self.log = ""
|
self.log = ""
|
||||||
try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath)
|
try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath)
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
|
|
||||||
guard self.isControlChannelConnected else {
|
guard self.isControlChannelConnected else {
|
||||||
menu.insertItem(self.makeMessageItem(
|
menu.insertItem(self.makeMessageItem(
|
||||||
text: "No connection to gateway",
|
text: self.controlChannelStatusText,
|
||||||
symbolName: "wifi.slash",
|
symbolName: "wifi.slash",
|
||||||
width: width), at: insertIndex)
|
width: width), at: insertIndex)
|
||||||
return
|
return
|
||||||
@@ -199,7 +199,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
|
|
||||||
guard self.isControlChannelConnected else {
|
guard self.isControlChannelConnected else {
|
||||||
menu.insertItem(
|
menu.insertItem(
|
||||||
self.makeMessageItem(text: "No connection to gateway", symbolName: "wifi.slash", width: width),
|
self.makeMessageItem(text: self.controlChannelStatusText, symbolName: "wifi.slash", width: width),
|
||||||
at: cursor)
|
at: cursor)
|
||||||
cursor += 1
|
cursor += 1
|
||||||
let separator = NSMenuItem.separator()
|
let separator = NSMenuItem.separator()
|
||||||
@@ -259,6 +259,29 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var controlChannelStatusText: String {
|
||||||
|
switch ControlChannel.shared.state {
|
||||||
|
case .connected:
|
||||||
|
return "Connected"
|
||||||
|
case .connecting:
|
||||||
|
return "Connecting to gateway…"
|
||||||
|
case let .degraded(reason):
|
||||||
|
if self.shouldShowConnecting { return "Connecting to gateway…" }
|
||||||
|
return reason.nonEmpty ?? "No connection to gateway"
|
||||||
|
case .disconnected:
|
||||||
|
return self.shouldShowConnecting ? "Connecting to gateway…" : "No connection to gateway"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shouldShowConnecting: Bool {
|
||||||
|
switch GatewayProcessManager.shared.status {
|
||||||
|
case .starting, .running, .attachedExisting:
|
||||||
|
return true
|
||||||
|
case .stopped, .failed:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem {
|
private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem {
|
||||||
let view = AnyView(
|
let view = AnyView(
|
||||||
Label(text, systemImage: symbolName)
|
Label(text, systemImage: symbolName)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ surface anything that needs attention without spamming the user.
|
|||||||
## Prompt contract
|
## Prompt contract
|
||||||
- Heartbeat body defaults to `HEARTBEAT` (configurable via `agent.heartbeat.prompt`).
|
- Heartbeat body defaults to `HEARTBEAT` (configurable via `agent.heartbeat.prompt`).
|
||||||
- If nothing needs attention, the model must reply **exactly** `HEARTBEAT_OK`.
|
- If nothing needs attention, the model must reply **exactly** `HEARTBEAT_OK`.
|
||||||
|
- Any response containing `HEARTBEAT_OK` is treated as an ack and discarded.
|
||||||
- For alerts, do **not** include `HEARTBEAT_OK`; return only the alert text.
|
- For alerts, do **not** include `HEARTBEAT_OK`; return only the alert text.
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ export function buildAgentSystemPromptAppend(params: {
|
|||||||
"## Heartbeats",
|
"## Heartbeats",
|
||||||
'If you receive a heartbeat poll (a user message containing just "HEARTBEAT"), and there is nothing that needs attention, reply exactly:',
|
'If you receive a heartbeat poll (a user message containing just "HEARTBEAT"), and there is nothing that needs attention, reply exactly:',
|
||||||
"HEARTBEAT_OK",
|
"HEARTBEAT_OK",
|
||||||
|
'Any response containing "HEARTBEAT_OK" is treated as a heartbeat ack and will not be delivered.',
|
||||||
'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.',
|
'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.',
|
||||||
"",
|
"",
|
||||||
"## Runtime",
|
"## Runtime",
|
||||||
|
|||||||
@@ -19,18 +19,15 @@ describe("stripHeartbeatToken", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps content and removes token when mixed", () => {
|
it("skips any reply that includes the heartbeat token", () => {
|
||||||
expect(stripHeartbeatToken(`ALERT ${HEARTBEAT_TOKEN}`)).toEqual({
|
expect(stripHeartbeatToken(`ALERT ${HEARTBEAT_TOKEN}`)).toEqual({
|
||||||
shouldSkip: false,
|
shouldSkip: true,
|
||||||
text: "ALERT",
|
text: "",
|
||||||
});
|
});
|
||||||
expect(stripHeartbeatToken("hello")).toEqual({
|
expect(stripHeartbeatToken("HEARTBEAT_OK 🦞")).toEqual({
|
||||||
shouldSkip: false,
|
shouldSkip: true,
|
||||||
text: "hello",
|
text: "",
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it("strips repeated OK tails after heartbeat token", () => {
|
|
||||||
expect(stripHeartbeatToken("HEARTBEAT_OK_OK_OK")).toEqual({
|
expect(stripHeartbeatToken("HEARTBEAT_OK_OK_OK")).toEqual({
|
||||||
shouldSkip: true,
|
shouldSkip: true,
|
||||||
text: "",
|
text: "",
|
||||||
@@ -48,8 +45,15 @@ describe("stripHeartbeatToken", () => {
|
|||||||
text: "",
|
text: "",
|
||||||
});
|
});
|
||||||
expect(stripHeartbeatToken("ALERT HEARTBEAT_OK_OK")).toEqual({
|
expect(stripHeartbeatToken("ALERT HEARTBEAT_OK_OK")).toEqual({
|
||||||
|
shouldSkip: true,
|
||||||
|
text: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps non-heartbeat content", () => {
|
||||||
|
expect(stripHeartbeatToken("hello")).toEqual({
|
||||||
shouldSkip: false,
|
shouldSkip: false,
|
||||||
text: "ALERT",
|
text: "hello",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,16 +6,8 @@ export function stripHeartbeatToken(raw?: string) {
|
|||||||
if (!raw) return { shouldSkip: true, text: "" };
|
if (!raw) return { shouldSkip: true, text: "" };
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return { shouldSkip: true, text: "" };
|
if (!trimmed) return { shouldSkip: true, text: "" };
|
||||||
if (trimmed === HEARTBEAT_TOKEN) return { shouldSkip: true, text: "" };
|
if (trimmed.includes(HEARTBEAT_TOKEN)) {
|
||||||
const hadToken = trimmed.includes(HEARTBEAT_TOKEN);
|
return { shouldSkip: true, text: "" };
|
||||||
let withoutToken = trimmed.replaceAll(HEARTBEAT_TOKEN, "").trim();
|
|
||||||
if (hadToken && withoutToken) {
|
|
||||||
// LLMs sometimes echo malformed HEARTBEAT_OK_OK... tails; strip trailing OK runs to avoid spam.
|
|
||||||
withoutToken = withoutToken.replace(/[\s_]*OK(?:[\s_]*OK)*$/gi, "").trim();
|
|
||||||
}
|
}
|
||||||
const shouldSkip = withoutToken.length === 0;
|
return { shouldSkip: false, text: trimmed };
|
||||||
return {
|
|
||||||
shouldSkip,
|
|
||||||
text: shouldSkip ? "" : withoutToken || trimmed,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user