fix: keep node presence fresh
This commit is contained in:
13
CHANGELOG.md
13
CHANGELOG.md
@@ -9,29 +9,24 @@
|
||||
- Sessions: primary session key is fixed to `main` (or `global` for global scope); `session.mainKey` is ignored.
|
||||
|
||||
### Features
|
||||
- Highlight: agent-to-agent ping-pong (reply-back loop) with `REPLY_SKIP` plus target announce step with `ANNOUNCE_SKIP` (max turns configurable, 0–5).
|
||||
- Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app.
|
||||
- Gateway: add config hot reload with hybrid restart strategy (`gateway.reload`) and per-section reload handling.
|
||||
- UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI.
|
||||
- Control UI: support configurable base paths (`gateway.controlUi.basePath`, default unchanged) for hosting under URL prefixes.
|
||||
- Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC.
|
||||
- Config: expose schema + UI hints for generic config forms (Web UI + future clients).
|
||||
- Browser: add multi-profile browser control with per-profile remote CDP URLs — thanks @jamesgroat.
|
||||
- Skills: add blogwatcher skill for RSS/Atom monitoring — thanks @Hyaxia.
|
||||
- Skills: add Notion API skill — thanks @scald.
|
||||
- Discord: emit system events for reaction add/remove with per-guild reaction notifications (off|own|all|allowlist) (#140) — thanks @thewilloftheshadow.
|
||||
- Slack: add socket-mode connector, tools, and UI/docs updates (#170) — thanks @thewilloftheshadow.
|
||||
- Agent: add optional per-session Docker sandbox for tool execution (`agent.sandbox`) with allow/deny policy and auto-pruning.
|
||||
- Agent: add sandboxed Chromium browser (CDP + optional noVNC observer) for sandboxed sessions.
|
||||
- Nodes: add `location.get` with Always/Precise settings on macOS/iOS/Android plus CLI/tool support.
|
||||
- Android nodes: add `sms.send` with permission-gated capability refresh (#172) — thanks @vsabavat.
|
||||
- Sessions: add agent‑to‑agent post step with `ANNOUNCE_SKIP` to suppress channel announcements.
|
||||
|
||||
### Fixes
|
||||
- macOS: improve Swift 6 strict concurrency compatibility (#166) — thanks @Nachx639.
|
||||
- Gateway/macOS: keep node presence fresh with periodic beacons + show presence status in Instances (#168) — thanks @mbelinky.
|
||||
- CI: fix lint ordering after merge cleanup (#156) — thanks @steipete.
|
||||
- CI: consolidate checks to avoid redundant installs (#144) — thanks @thewilloftheshadow.
|
||||
- WhatsApp: support `gifPlayback` for MP4 GIF sends via CLI/gateway.
|
||||
- Gateway: log config hot reloads for dynamic-read changes without restarts.
|
||||
- Sessions: prevent `sessions_send` timeouts by running nested agent turns on a separate lane.
|
||||
- Sessions: use per-send run IDs for gateway agent calls to avoid wait collisions.
|
||||
- Auto-reply: drop final payloads when block streaming to avoid duplicate Discord sends.
|
||||
@@ -64,7 +59,6 @@
|
||||
- Build: drop stale ClawdisCLI product from macOS build-and-run script.
|
||||
- Auto-reply: add run-level telemetry + typing TTL guardrails to diagnose stuck replies.
|
||||
- WhatsApp: honor per-group mention gating overrides when group ids are stored as session keys.
|
||||
- Slack: add missing deps and wire Slack into cron/heartbeat/hook delivery.
|
||||
- Dependencies: bump pi-mono packages to 0.32.3.
|
||||
|
||||
### Docs
|
||||
@@ -79,8 +73,7 @@
|
||||
- Queue: clarify steer-backlog behavior with inline commands and update examples for streaming surfaces.
|
||||
- Sandbox: document per-session agent sandbox setup, browser image, and Docker build.
|
||||
- macOS: clarify menu bar uses sessionKey from agent events.
|
||||
- Sessions: document agent-to-agent reply loop (`REPLY_SKIP`) and announce step (`ANNOUNCE_SKIP`).
|
||||
- Skills: clarify wacli third-party messaging scope and JID format examples.
|
||||
- Sessions: document agent-to-agent post step and `ANNOUNCE_SKIP`.
|
||||
|
||||
## 2.0.0-beta5 — 2026-01-03
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ struct InstancesSettings: View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Text(inst.host ?? "unknown host").font(.subheadline.bold())
|
||||
self.presenceIndicator(inst)
|
||||
if let ip = inst.ip { Text("(") + Text(ip).monospaced() + Text(")") }
|
||||
}
|
||||
|
||||
@@ -146,6 +147,29 @@ struct InstancesSettings: View {
|
||||
.font(.footnote)
|
||||
}
|
||||
|
||||
private func presenceIndicator(_ inst: InstanceInfo) -> some View {
|
||||
let status = self.presenceStatus(for: inst)
|
||||
return HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(status.color)
|
||||
.frame(width: 6, height: 6)
|
||||
.accessibilityHidden(true)
|
||||
Text(status.label)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.caption)
|
||||
.help("Presence updated \(inst.ageDescription).")
|
||||
.accessibilityLabel("\(status.label) presence")
|
||||
}
|
||||
|
||||
private func presenceStatus(for inst: InstanceInfo) -> (label: String, color: Color) {
|
||||
let nowMs = Date().timeIntervalSince1970 * 1000
|
||||
let ageSeconds = max(0, Int((nowMs - inst.ts) / 1000))
|
||||
if ageSeconds <= 120 { return ("Active", .green) }
|
||||
if ageSeconds <= 300 { return ("Idle", .yellow) }
|
||||
return ("Stale", .gray)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func leadingDeviceIcon(_ inst: InstanceInfo, device: DevicePresentation?) -> some View {
|
||||
let symbol = self.leadingDeviceSymbol(inst, device: device)
|
||||
@@ -307,6 +331,10 @@ struct InstancesSettings: View {
|
||||
return "Connect"
|
||||
case "disconnect":
|
||||
return "Disconnect"
|
||||
case "node-connected":
|
||||
return "Node connect"
|
||||
case "node-disconnected":
|
||||
return "Node disconnect"
|
||||
case "launch":
|
||||
return "Launch"
|
||||
case "periodic":
|
||||
|
||||
@@ -100,7 +100,7 @@ Pairing details: `docs/gateway/pairing.md`.
|
||||
|
||||
## 5) Verify the node is connected
|
||||
|
||||
- In the macOS app: **Instances** tab should show something like `iOS Node (...)`.
|
||||
- In the macOS app: **Instances** tab should show something like `iOS Node (...)` with a green “Active” presence dot shortly after connect.
|
||||
- Via nodes status (paired + connected):
|
||||
```bash
|
||||
clawdis nodes status
|
||||
|
||||
@@ -24,7 +24,7 @@ Presence entries are structured objects with (some) fields:
|
||||
- `modelIdentifier` (optional): hardware model identifier like `iPad16,6` or `Mac16,6`
|
||||
- `mode`: e.g. `gateway`, `app`, `webchat`, `cli`
|
||||
- `lastInputSeconds` (optional): “seconds since last user input” for that client machine
|
||||
- `reason`: a short marker like `self`, `connect`, `periodic`, `instances-refresh`
|
||||
- `reason`: a short marker like `self`, `connect`, `node-connected`, `node-disconnected`, `periodic`, `instances-refresh`
|
||||
- `text`: legacy/debug summary string (kept for backwards compatibility and UI display)
|
||||
- `ts`: last update timestamp (ms since epoch)
|
||||
|
||||
@@ -61,6 +61,16 @@ Implementation:
|
||||
- Gateway: `src/gateway/server.ts` handles method `system-event` by calling `updateSystemPresence(...)`.
|
||||
- mac app beaconing: `apps/macos/Sources/Clawdis/PresenceReporter.swift`.
|
||||
|
||||
### 4) Node bridge beacons (gateway-owned presence)
|
||||
|
||||
When a node bridge connection authenticates, the Gateway emits a presence entry
|
||||
for that node and starts periodic refresh beacons so it does not expire.
|
||||
|
||||
- Connect/disconnect markers: `node-connected`, `node-disconnected`
|
||||
- Periodic heartbeat: every 3 minutes (`reason: periodic`)
|
||||
|
||||
Implementation: `src/gateway/server.ts` (node bridge handlers + timer beacons).
|
||||
|
||||
## Merge + dedupe rules (why `instanceId` matters)
|
||||
|
||||
All producers write into a single in-memory presence map.
|
||||
@@ -109,6 +119,9 @@ Implementation:
|
||||
- View: `apps/macos/Sources/Clawdis/InstancesSettings.swift`
|
||||
- Store: `apps/macos/Sources/Clawdis/InstancesStore.swift`
|
||||
|
||||
The Instances rows show a small presence indicator (Active/Idle/Stale) based on
|
||||
the last beacon age. The label is derived from the entry timestamp (`ts`).
|
||||
|
||||
The store refreshes periodically and also applies `presence` WS events.
|
||||
|
||||
## Debugging tips
|
||||
|
||||
@@ -212,7 +212,7 @@ git worktree remove /tmp/issue-99
|
||||
|
||||
When submitting PRs to external repos, use this format for quality & maintainer-friendliness:
|
||||
|
||||
```markdown
|
||||
````markdown
|
||||
## Original Prompt
|
||||
[Exact request/problem statement]
|
||||
|
||||
@@ -228,7 +228,7 @@ When submitting PRs to external repos, use this format for quality & maintainer-
|
||||
# Example
|
||||
command example
|
||||
```
|
||||
```
|
||||
````
|
||||
|
||||
## Feature intent (maintainer-friendly)
|
||||
[Why useful, how it fits, workflows it enables]
|
||||
|
||||
@@ -7,11 +7,7 @@ vi.mock("../gateway/call.js", () => ({
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: () => ({
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
agentToAgent: { maxPingPongTurns: 2 },
|
||||
},
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
}),
|
||||
resolveGatewayPort: () => 18789,
|
||||
}));
|
||||
@@ -131,28 +127,18 @@ describe("sessions tools", () => {
|
||||
let agentCallCount = 0;
|
||||
let _historyCallCount = 0;
|
||||
let sendCallCount = 0;
|
||||
let lastWaitedRunId: string | undefined;
|
||||
const replyByRunId = new Map<string, string>();
|
||||
const requesterKey = "discord:group:req";
|
||||
let waitRunId: string | undefined;
|
||||
let nextHistoryIsWaitReply = false;
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string; params?: unknown };
|
||||
calls.push(request);
|
||||
if (request.method === "agent") {
|
||||
agentCallCount += 1;
|
||||
const runId = `run-${agentCallCount}`;
|
||||
const params = request.params as
|
||||
| { message?: string; sessionKey?: string }
|
||||
| undefined;
|
||||
const message = params?.message ?? "";
|
||||
let reply = "REPLY_SKIP";
|
||||
if (message === "ping" || message === "wait") {
|
||||
reply = "done";
|
||||
} else if (message === "Agent-to-agent announce step.") {
|
||||
reply = "ANNOUNCE_SKIP";
|
||||
} else if (params?.sessionKey === requesterKey) {
|
||||
reply = "pong";
|
||||
const params = request.params as { message?: string } | undefined;
|
||||
if (params?.message === "wait") {
|
||||
waitRunId = runId;
|
||||
}
|
||||
replyByRunId.set(runId, reply);
|
||||
return {
|
||||
runId,
|
||||
status: "accepted",
|
||||
@@ -161,13 +147,15 @@ describe("sessions tools", () => {
|
||||
}
|
||||
if (request.method === "agent.wait") {
|
||||
const params = request.params as { runId?: string } | undefined;
|
||||
lastWaitedRunId = params?.runId;
|
||||
if (params?.runId && params.runId === waitRunId) {
|
||||
nextHistoryIsWaitReply = true;
|
||||
}
|
||||
return { runId: params?.runId ?? "run-1", status: "ok" };
|
||||
}
|
||||
if (request.method === "chat.history") {
|
||||
_historyCallCount += 1;
|
||||
const text =
|
||||
(lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
|
||||
const text = nextHistoryIsWaitReply ? "done" : "ANNOUNCE_SKIP";
|
||||
nextHistoryIsWaitReply = false;
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
@@ -190,10 +178,9 @@ describe("sessions tools", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createClawdisTools({
|
||||
agentSessionKey: requesterKey,
|
||||
agentSurface: "discord",
|
||||
}).find((candidate) => candidate.name === "sessions_send");
|
||||
const tool = createClawdisTools().find(
|
||||
(candidate) => candidate.name === "sessions_send",
|
||||
);
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("missing sessions_send tool");
|
||||
|
||||
@@ -204,7 +191,6 @@ describe("sessions tools", () => {
|
||||
});
|
||||
expect(fire.details).toMatchObject({ status: "accepted", runId: "run-1" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const waitPromise = tool.execute("call6", {
|
||||
sessionKey: "main",
|
||||
@@ -218,14 +204,13 @@ describe("sessions tools", () => {
|
||||
});
|
||||
expect(typeof (waited.details as { runId?: string }).runId).toBe("string");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const agentCalls = calls.filter((call) => call.method === "agent");
|
||||
const waitCalls = calls.filter((call) => call.method === "agent.wait");
|
||||
const historyOnlyCalls = calls.filter(
|
||||
(call) => call.method === "chat.history",
|
||||
);
|
||||
expect(agentCalls).toHaveLength(8);
|
||||
expect(agentCalls).toHaveLength(4);
|
||||
for (const call of agentCalls) {
|
||||
expect(call.params).toMatchObject({ lane: "nested" });
|
||||
}
|
||||
@@ -246,21 +231,11 @@ describe("sessions tools", () => {
|
||||
?.extraSystemPrompt === "string" &&
|
||||
(
|
||||
call.params as { extraSystemPrompt?: string }
|
||||
)?.extraSystemPrompt?.includes("Agent-to-agent reply step"),
|
||||
)?.extraSystemPrompt?.includes("Agent-to-agent post step"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
agentCalls.some(
|
||||
(call) =>
|
||||
typeof (call.params as { extraSystemPrompt?: string })
|
||||
?.extraSystemPrompt === "string" &&
|
||||
(
|
||||
call.params as { extraSystemPrompt?: string }
|
||||
)?.extraSystemPrompt?.includes("Agent-to-agent announce step"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(waitCalls).toHaveLength(8);
|
||||
expect(historyOnlyCalls).toHaveLength(8);
|
||||
expect(waitCalls).toHaveLength(3);
|
||||
expect(historyOnlyCalls).toHaveLength(3);
|
||||
expect(
|
||||
waitCalls.some(
|
||||
(call) =>
|
||||
@@ -269,110 +244,4 @@ describe("sessions tools", () => {
|
||||
).toBe(true);
|
||||
expect(sendCallCount).toBe(0);
|
||||
});
|
||||
|
||||
it("sessions_send runs ping-pong then announces", async () => {
|
||||
callGatewayMock.mockReset();
|
||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||
let agentCallCount = 0;
|
||||
let lastWaitedRunId: string | undefined;
|
||||
const replyByRunId = new Map<string, string>();
|
||||
const requesterKey = "discord:group:req";
|
||||
const targetKey = "discord:group:target";
|
||||
let sendParams: { to?: string; provider?: string; message?: string } = {};
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string; params?: unknown };
|
||||
calls.push(request);
|
||||
if (request.method === "agent") {
|
||||
agentCallCount += 1;
|
||||
const runId = `run-${agentCallCount}`;
|
||||
const params = request.params as
|
||||
| {
|
||||
message?: string;
|
||||
sessionKey?: string;
|
||||
extraSystemPrompt?: string;
|
||||
}
|
||||
| undefined;
|
||||
let reply = "initial";
|
||||
if (params?.extraSystemPrompt?.includes("Agent-to-agent reply step")) {
|
||||
reply = params.sessionKey === requesterKey ? "pong-1" : "pong-2";
|
||||
}
|
||||
if (
|
||||
params?.extraSystemPrompt?.includes("Agent-to-agent announce step")
|
||||
) {
|
||||
reply = "announce now";
|
||||
}
|
||||
replyByRunId.set(runId, reply);
|
||||
return {
|
||||
runId,
|
||||
status: "accepted",
|
||||
acceptedAt: 2000 + agentCallCount,
|
||||
};
|
||||
}
|
||||
if (request.method === "agent.wait") {
|
||||
const params = request.params as { runId?: string } | undefined;
|
||||
lastWaitedRunId = params?.runId;
|
||||
return { runId: params?.runId ?? "run-1", status: "ok" };
|
||||
}
|
||||
if (request.method === "chat.history") {
|
||||
const text =
|
||||
(lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
timestamp: 20,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (request.method === "send") {
|
||||
const params = request.params as
|
||||
| { to?: string; provider?: string; message?: string }
|
||||
| undefined;
|
||||
sendParams = {
|
||||
to: params?.to,
|
||||
provider: params?.provider,
|
||||
message: params?.message,
|
||||
};
|
||||
return { messageId: "m-announce" };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createClawdisTools({
|
||||
agentSessionKey: requesterKey,
|
||||
agentSurface: "discord",
|
||||
}).find((candidate) => candidate.name === "sessions_send");
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("missing sessions_send tool");
|
||||
|
||||
const waited = await tool.execute("call7", {
|
||||
sessionKey: targetKey,
|
||||
message: "ping",
|
||||
timeoutSeconds: 1,
|
||||
});
|
||||
expect(waited.details).toMatchObject({
|
||||
status: "ok",
|
||||
reply: "initial",
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const replySteps = calls.filter(
|
||||
(call) =>
|
||||
call.method === "agent" &&
|
||||
typeof (call.params as { extraSystemPrompt?: string })
|
||||
?.extraSystemPrompt === "string" &&
|
||||
(
|
||||
call.params as { extraSystemPrompt?: string }
|
||||
)?.extraSystemPrompt?.includes("Agent-to-agent reply step"),
|
||||
);
|
||||
expect(replySteps).toHaveLength(2);
|
||||
expect(sendParams).toMatchObject({
|
||||
to: "channel:target",
|
||||
provider: "discord",
|
||||
message: "announce now",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -946,15 +946,18 @@ export async function startGatewayServer(
|
||||
nodePresenceTimers.delete(nodeId);
|
||||
};
|
||||
|
||||
const beaconNodePresence = (node: {
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
remoteIp?: string;
|
||||
version?: string;
|
||||
platform?: string;
|
||||
deviceFamily?: string;
|
||||
modelIdentifier?: string;
|
||||
}, reason: string) => {
|
||||
const beaconNodePresence = (
|
||||
node: {
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
remoteIp?: string;
|
||||
version?: string;
|
||||
platform?: string;
|
||||
deviceFamily?: string;
|
||||
modelIdentifier?: string;
|
||||
},
|
||||
reason: string,
|
||||
) => {
|
||||
const host = node.displayName?.trim() || node.nodeId;
|
||||
const rawIp = node.remoteIp?.trim();
|
||||
const ip = rawIp && !isLoopbackAddress(rawIp) ? rawIp : undefined;
|
||||
@@ -1826,6 +1829,10 @@ export async function startGatewayServer(
|
||||
await stopGmailWatcher();
|
||||
cron.stop();
|
||||
heartbeatRunner.stop();
|
||||
for (const timer of nodePresenceTimers.values()) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
nodePresenceTimers.clear();
|
||||
broadcast("shutdown", {
|
||||
reason,
|
||||
restartExpectedMs,
|
||||
|
||||
Reference in New Issue
Block a user