fix: harden heartbeat acks + gateway reconnect
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
|
||||
### Fixes
|
||||
- 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
|
||||
|
||||
|
||||
@@ -182,6 +182,7 @@ final class GatewayProcessManager {
|
||||
self.existingGatewayDetails = details
|
||||
self.status = .attachedExisting(details: details)
|
||||
self.appendLog("[gateway] using existing instance: \(details)\n")
|
||||
self.refreshControlChannelIfNeeded(reason: "attach existing")
|
||||
self.refreshLog()
|
||||
return true
|
||||
} catch {
|
||||
@@ -289,6 +290,7 @@ final class GatewayProcessManager {
|
||||
let instance = await PortGuardian.shared.describe(port: port)
|
||||
let details = instance.map { "pid \($0.pid)" }
|
||||
self.status = .running(details: details)
|
||||
self.refreshControlChannelIfNeeded(reason: "gateway started")
|
||||
self.refreshLog()
|
||||
return
|
||||
} 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() {
|
||||
self.log = ""
|
||||
try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath)
|
||||
|
||||
@@ -110,7 +110,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
menu.insertItem(self.makeMessageItem(
|
||||
text: "No connection to gateway",
|
||||
text: self.controlChannelStatusText,
|
||||
symbolName: "wifi.slash",
|
||||
width: width), at: insertIndex)
|
||||
return
|
||||
@@ -199,7 +199,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
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)
|
||||
cursor += 1
|
||||
let separator = NSMenuItem.separator()
|
||||
@@ -259,6 +259,29 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
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 {
|
||||
let view = AnyView(
|
||||
Label(text, systemImage: symbolName)
|
||||
|
||||
@@ -11,6 +11,7 @@ surface anything that needs attention without spamming the user.
|
||||
## Prompt contract
|
||||
- Heartbeat body defaults to `HEARTBEAT` (configurable via `agent.heartbeat.prompt`).
|
||||
- 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.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -95,6 +95,7 @@ export function buildAgentSystemPromptAppend(params: {
|
||||
"## Heartbeats",
|
||||
'If you receive a heartbeat poll (a user message containing just "HEARTBEAT"), and there is nothing that needs attention, reply exactly:',
|
||||
"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.',
|
||||
"",
|
||||
"## 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({
|
||||
shouldSkip: false,
|
||||
text: "ALERT",
|
||||
shouldSkip: true,
|
||||
text: "",
|
||||
});
|
||||
expect(stripHeartbeatToken("hello")).toEqual({
|
||||
shouldSkip: false,
|
||||
text: "hello",
|
||||
expect(stripHeartbeatToken("HEARTBEAT_OK 🦞")).toEqual({
|
||||
shouldSkip: true,
|
||||
text: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips repeated OK tails after heartbeat token", () => {
|
||||
expect(stripHeartbeatToken("HEARTBEAT_OK_OK_OK")).toEqual({
|
||||
shouldSkip: true,
|
||||
text: "",
|
||||
@@ -48,8 +45,15 @@ describe("stripHeartbeatToken", () => {
|
||||
text: "",
|
||||
});
|
||||
expect(stripHeartbeatToken("ALERT HEARTBEAT_OK_OK")).toEqual({
|
||||
shouldSkip: true,
|
||||
text: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps non-heartbeat content", () => {
|
||||
expect(stripHeartbeatToken("hello")).toEqual({
|
||||
shouldSkip: false,
|
||||
text: "ALERT",
|
||||
text: "hello",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,16 +6,8 @@ export function stripHeartbeatToken(raw?: string) {
|
||||
if (!raw) return { shouldSkip: true, text: "" };
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { shouldSkip: true, text: "" };
|
||||
if (trimmed === HEARTBEAT_TOKEN) return { shouldSkip: true, text: "" };
|
||||
const hadToken = trimmed.includes(HEARTBEAT_TOKEN);
|
||||
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();
|
||||
if (trimmed.includes(HEARTBEAT_TOKEN)) {
|
||||
return { shouldSkip: true, text: "" };
|
||||
}
|
||||
const shouldSkip = withoutToken.length === 0;
|
||||
return {
|
||||
shouldSkip,
|
||||
text: shouldSkip ? "" : withoutToken || trimmed,
|
||||
};
|
||||
return { shouldSkip: false, text: trimmed };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user