feat: move heartbeat config to agent.heartbeat
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
- Config refactor: `inbound.*` removed; use top-level `routing` (allowlists + group rules + transcription), `messages` (prefixes/timestamps), and `session` (scoping/store/mainKey). No legacy keys read.
|
- Config refactor: `inbound.*` removed; use top-level `routing` (allowlists + group rules + transcription), `messages` (prefixes/timestamps), and `session` (scoping/store/mainKey). No legacy keys read.
|
||||||
|
- Heartbeat config moved to `agent.heartbeat`: set `every: "30m"` (duration string) and optional `model`. `agent.heartbeatMinutes` is removed, and heartbeats are disabled unless `agent.heartbeat.every` is set.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Heartbeat replies now strip repeated `HEARTBEAT_OK` tails to avoid accidental “OK OK” spam.
|
- Heartbeat replies now strip repeated `HEARTBEAT_OK` tails to avoid accidental “OK OK” spam.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ You’re putting an agent in a position to:
|
|||||||
Start conservative:
|
Start conservative:
|
||||||
- Always set `routing.allowFrom` (never run open-to-the-world on your personal Mac).
|
- Always set `routing.allowFrom` (never run open-to-the-world on your personal Mac).
|
||||||
- Use a dedicated WhatsApp number for the assistant.
|
- Use a dedicated WhatsApp number for the assistant.
|
||||||
- Keep heartbeats disabled until you trust the setup (`heartbeatMinutes: 0`).
|
- Keep heartbeats disabled until you trust the setup (omit `agent.heartbeat` or set `agent.heartbeat.every: "0m"`).
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ Example:
|
|||||||
thinkingDefault: "high",
|
thinkingDefault: "high",
|
||||||
timeoutSeconds: 1800,
|
timeoutSeconds: 1800,
|
||||||
// Start with 0; enable later.
|
// Start with 0; enable later.
|
||||||
heartbeatMinutes: 0
|
heartbeat: { every: "0m" }
|
||||||
},
|
},
|
||||||
routing: {
|
routing: {
|
||||||
allowFrom: ["+15555550123"],
|
allowFrom: ["+15555550123"],
|
||||||
@@ -148,14 +148,14 @@ Example:
|
|||||||
|
|
||||||
## Heartbeats (proactive mode)
|
## Heartbeats (proactive mode)
|
||||||
|
|
||||||
When `agent.heartbeatMinutes > 0`, CLAWDIS periodically runs a heartbeat prompt (default: `HEARTBEAT`).
|
When `agent.heartbeat.every` is set to a positive interval, CLAWDIS periodically runs a heartbeat prompt (default: `HEARTBEAT`).
|
||||||
|
|
||||||
- If the agent replies with `HEARTBEAT_OK` (exact token), CLAWDIS suppresses outbound delivery for that heartbeat.
|
- If the agent replies with `HEARTBEAT_OK` (exact token), CLAWDIS suppresses outbound delivery for that heartbeat.
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
agent: {
|
agent: {
|
||||||
heartbeatMinutes: 30
|
heartbeat: { every: "30m" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -129,7 +129,9 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
|
|||||||
verboseDefault: "off",
|
verboseDefault: "off",
|
||||||
timeoutSeconds: 600,
|
timeoutSeconds: 600,
|
||||||
mediaMaxMb: 5,
|
mediaMaxMb: 5,
|
||||||
heartbeatMinutes: 30,
|
heartbeat: {
|
||||||
|
every: "30m"
|
||||||
|
},
|
||||||
maxConcurrent: 3,
|
maxConcurrent: 3,
|
||||||
bash: {
|
bash: {
|
||||||
backgroundMs: 20000,
|
backgroundMs: 20000,
|
||||||
@@ -145,6 +147,11 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
|
|||||||
If you omit the provider, CLAWDIS currently assumes `anthropic` as a temporary
|
If you omit the provider, CLAWDIS currently assumes `anthropic` as a temporary
|
||||||
deprecation fallback.
|
deprecation fallback.
|
||||||
|
|
||||||
|
`agent.heartbeat` configures periodic heartbeat runs:
|
||||||
|
- `every`: duration string (`ms`, `s`, `m`); default unit minutes. Omit or set
|
||||||
|
`0m` to disable.
|
||||||
|
- `model`: optional override model for heartbeat runs (`provider/model`).
|
||||||
|
|
||||||
`agent.bash` configures background bash defaults:
|
`agent.bash` configures background bash defaults:
|
||||||
- `backgroundMs`: time before auto-background (ms, default 20000)
|
- `backgroundMs`: time before auto-background (ms, default 20000)
|
||||||
- `timeoutSec`: auto-kill after this runtime (seconds, default 1800)
|
- `timeoutSec`: auto-kill after this runtime (seconds, default 1800)
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ Goal: add a simple heartbeat poll for the embedded agent that only notifies user
|
|||||||
- Keep existing WhatsApp length guidance; forbid burying the sentinel inside alerts.
|
- Keep existing WhatsApp length guidance; forbid burying the sentinel inside alerts.
|
||||||
|
|
||||||
## Config & defaults
|
## Config & defaults
|
||||||
- New config key: `agent.heartbeatMinutes` (number of minutes; `0` disables).
|
- New config key: `agent.heartbeat` with:
|
||||||
- Default: 30 minutes.
|
- `every`: duration string (`ms`, `s`, `m`; default unit minutes). `0m` disables.
|
||||||
|
- `model`: optional override model (`provider/model`) for heartbeat runs.
|
||||||
|
- Default: disabled unless `agent.heartbeat.every` is set.
|
||||||
- New optional idle override for heartbeats: `session.heartbeatIdleMinutes` (defaults to `idleMinutes`). Heartbeat skips do **not** update the session `updatedAt` so idle expiry still works.
|
- New optional idle override for heartbeats: `session.heartbeatIdleMinutes` (defaults to `idleMinutes`). Heartbeat skips do **not** update the session `updatedAt` so idle expiry still works.
|
||||||
|
|
||||||
## Poller behavior
|
## Poller behavior
|
||||||
@@ -40,7 +42,7 @@ Goal: add a simple heartbeat poll for the embedded agent that only notifies user
|
|||||||
- Unit/integration: verbose logger emits start/end lines; normal logger emits a single line.
|
- Unit/integration: verbose logger emits start/end lines; normal logger emits a single line.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
- Add a short README snippet under configuration showing `heartbeatMinutes` and the sentinel rule.
|
- Add a short README snippet under configuration showing `agent.heartbeat` and the sentinel rule.
|
||||||
- Expose CLI triggers:
|
- Expose CLI triggers:
|
||||||
- `clawdis heartbeat` (web provider, defaults to first `routing.allowFrom`; optional `--to` override)
|
- `clawdis heartbeat` (web provider, defaults to first `routing.allowFrom`; optional `--to` override)
|
||||||
- `--session-id <uuid>` forces resuming a specific session for that heartbeat
|
- `--session-id <uuid>` forces resuming a specific session for that heartbeat
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session.
|
|||||||
|
|
||||||
## Heartbeats
|
## Heartbeats
|
||||||
- **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s).
|
- **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s).
|
||||||
- **Reply heartbeat** asks agent on a timer (`agent.heartbeatMinutes`).
|
- **Reply heartbeat** asks agent on a timer (`agent.heartbeat.every`).
|
||||||
- Uses `HEARTBEAT` prompt + `HEARTBEAT_TOKEN` skip behavior.
|
- Uses `HEARTBEAT` prompt + `HEARTBEAT_TOKEN` skip behavior.
|
||||||
- Skips if queue busy or last inbound was a group.
|
- Skips if queue busy or last inbound was a group.
|
||||||
- Falls back to last direct recipient if needed.
|
- Falls back to last direct recipient if needed.
|
||||||
@@ -104,7 +104,8 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session.
|
|||||||
- `messages.messagePrefix` (inbound prefix)
|
- `messages.messagePrefix` (inbound prefix)
|
||||||
- `messages.responsePrefix` (outbound prefix)
|
- `messages.responsePrefix` (outbound prefix)
|
||||||
- `agent.mediaMaxMb`
|
- `agent.mediaMaxMb`
|
||||||
- `agent.heartbeatMinutes`
|
- `agent.heartbeat.every`
|
||||||
|
- `agent.heartbeat.model` (optional override)
|
||||||
- `session.*` (scope, idle, store, mainKey)
|
- `session.*` (scope, idle, store, mainKey)
|
||||||
- `web.heartbeatSeconds`
|
- `web.heartbeatSeconds`
|
||||||
- `web.reconnect.*`
|
- `web.reconnect.*`
|
||||||
|
|||||||
@@ -101,6 +101,38 @@ describe("trigger handling", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses heartbeat model override for heartbeat runs", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||||
|
payloads: [{ text: "ok" }],
|
||||||
|
meta: {
|
||||||
|
durationMs: 1,
|
||||||
|
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const cfg = makeCfg(home);
|
||||||
|
cfg.agent = {
|
||||||
|
...cfg.agent,
|
||||||
|
heartbeat: { model: "anthropic/claude-haiku-4-5-20251001" },
|
||||||
|
};
|
||||||
|
|
||||||
|
await getReplyFromConfig(
|
||||||
|
{
|
||||||
|
Body: "hello",
|
||||||
|
From: "+1002",
|
||||||
|
To: "+2000",
|
||||||
|
},
|
||||||
|
{ isHeartbeat: true },
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
|
||||||
|
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
|
||||||
|
expect(call?.provider).toBe("anthropic");
|
||||||
|
expect(call?.model).toBe("claude-haiku-4-5-20251001");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("updates group activation when the owner sends /activation", async () => {
|
it("updates group activation when the owner sends /activation", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const cfg = makeCfg(home);
|
const cfg = makeCfg(home);
|
||||||
|
|||||||
@@ -169,14 +169,25 @@ export async function getReplyFromConfig(
|
|||||||
const agentCfg = cfg.agent;
|
const agentCfg = cfg.agent;
|
||||||
const sessionCfg = cfg.session;
|
const sessionCfg = cfg.session;
|
||||||
|
|
||||||
const { provider: defaultProvider, model: defaultModel } =
|
const mainModel = resolveConfiguredModelRef({
|
||||||
resolveConfiguredModelRef({
|
cfg,
|
||||||
cfg,
|
defaultProvider: DEFAULT_PROVIDER,
|
||||||
defaultProvider: DEFAULT_PROVIDER,
|
defaultModel: DEFAULT_MODEL,
|
||||||
defaultModel: DEFAULT_MODEL,
|
});
|
||||||
});
|
const defaultProvider = mainModel.provider;
|
||||||
|
const defaultModel = mainModel.model;
|
||||||
let provider = defaultProvider;
|
let provider = defaultProvider;
|
||||||
let model = defaultModel;
|
let model = defaultModel;
|
||||||
|
if (opts?.isHeartbeat) {
|
||||||
|
const heartbeatRaw = agentCfg?.heartbeat?.model?.trim() ?? "";
|
||||||
|
const heartbeatRef = heartbeatRaw
|
||||||
|
? parseModelRef(heartbeatRaw, defaultProvider)
|
||||||
|
: null;
|
||||||
|
if (heartbeatRef) {
|
||||||
|
provider = heartbeatRef.provider;
|
||||||
|
model = heartbeatRef.model;
|
||||||
|
}
|
||||||
|
}
|
||||||
let contextTokens =
|
let contextTokens =
|
||||||
agentCfg?.contextTokens ??
|
agentCfg?.contextTokens ??
|
||||||
lookupContextTokens(model) ??
|
lookupContextTokens(model) ??
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import path from "node:path";
|
|||||||
import JSON5 from "json5";
|
import JSON5 from "json5";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||||
|
|
||||||
export type SessionScope = "per-sender" | "global";
|
export type SessionScope = "per-sender" | "global";
|
||||||
|
|
||||||
export type SessionConfig = {
|
export type SessionConfig = {
|
||||||
@@ -305,7 +307,6 @@ export type ClawdisConfig = {
|
|||||||
skillsInstall?: SkillsInstallConfig;
|
skillsInstall?: SkillsInstallConfig;
|
||||||
models?: ModelsConfig;
|
models?: ModelsConfig;
|
||||||
agent?: {
|
agent?: {
|
||||||
/** Provider id, e.g. "anthropic" or "openai" (pi-ai catalog). */
|
|
||||||
/** Model id (provider/model), e.g. "anthropic/claude-opus-4-5". */
|
/** Model id (provider/model), e.g. "anthropic/claude-opus-4-5". */
|
||||||
model?: string;
|
model?: string;
|
||||||
/** Agent working directory (preferred). Used as the default cwd for agent runs. */
|
/** Agent working directory (preferred). Used as the default cwd for agent runs. */
|
||||||
@@ -322,8 +323,13 @@ export type ClawdisConfig = {
|
|||||||
/** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */
|
/** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
typingIntervalSeconds?: number;
|
typingIntervalSeconds?: number;
|
||||||
/** Periodic background heartbeat runs (minutes). 0 disables. */
|
/** Periodic background heartbeat runs. */
|
||||||
heartbeatMinutes?: number;
|
heartbeat?: {
|
||||||
|
/** Heartbeat interval (duration string, default unit: minutes). */
|
||||||
|
every?: string;
|
||||||
|
/** Heartbeat model override (provider/model). */
|
||||||
|
model?: string;
|
||||||
|
};
|
||||||
/** Max concurrent agent runs across all conversations. Default: 1 (sequential). */
|
/** Max concurrent agent runs across all conversations. Default: 1 (sequential). */
|
||||||
maxConcurrent?: number;
|
maxConcurrent?: number;
|
||||||
/** Bash tool defaults. */
|
/** Bash tool defaults. */
|
||||||
@@ -444,6 +450,25 @@ const MessagesSchema = z
|
|||||||
})
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
|
const HeartbeatSchema = z
|
||||||
|
.object({
|
||||||
|
every: z.string().optional(),
|
||||||
|
model: z.string().optional(),
|
||||||
|
})
|
||||||
|
.superRefine((val, ctx) => {
|
||||||
|
if (!val.every) return;
|
||||||
|
try {
|
||||||
|
parseDurationMs(val.every, { defaultUnit: "m" });
|
||||||
|
} catch {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["every"],
|
||||||
|
message: "invalid duration (use ms, s, m)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
const RoutingSchema = z
|
const RoutingSchema = z
|
||||||
.object({
|
.object({
|
||||||
allowFrom: z.array(z.string()).optional(),
|
allowFrom: z.array(z.string()).optional(),
|
||||||
@@ -581,7 +606,7 @@ const ClawdisSchema = z.object({
|
|||||||
timeoutSeconds: z.number().int().positive().optional(),
|
timeoutSeconds: z.number().int().positive().optional(),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
typingIntervalSeconds: z.number().int().positive().optional(),
|
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||||
heartbeatMinutes: z.number().nonnegative().optional(),
|
heartbeat: HeartbeatSchema,
|
||||||
maxConcurrent: z.number().int().positive().optional(),
|
maxConcurrent: z.number().int().positive().optional(),
|
||||||
bash: z
|
bash: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
HEARTBEAT_TOKEN,
|
HEARTBEAT_TOKEN,
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
resolveHeartbeatRecipients,
|
resolveHeartbeatRecipients,
|
||||||
resolveReplyHeartbeatMinutes,
|
resolveReplyHeartbeatIntervalMs,
|
||||||
runWebHeartbeatOnce,
|
runWebHeartbeatOnce,
|
||||||
SILENT_REPLY_TOKEN,
|
SILENT_REPLY_TOKEN,
|
||||||
stripHeartbeatToken,
|
stripHeartbeatToken,
|
||||||
@@ -157,20 +157,25 @@ describe("heartbeat helpers", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves heartbeat minutes with default and overrides", () => {
|
it("resolves reply heartbeat interval from config and overrides", () => {
|
||||||
const cfgBase: ClawdisConfig = {};
|
const cfgBase: ClawdisConfig = {};
|
||||||
expect(resolveReplyHeartbeatMinutes(cfgBase)).toBe(30);
|
expect(resolveReplyHeartbeatIntervalMs(cfgBase)).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
resolveReplyHeartbeatMinutes({
|
resolveReplyHeartbeatIntervalMs({
|
||||||
agent: { heartbeatMinutes: 5 },
|
agent: { heartbeat: { every: "5m" } },
|
||||||
}),
|
}),
|
||||||
).toBe(5);
|
).toBe(5 * 60_000);
|
||||||
expect(
|
expect(
|
||||||
resolveReplyHeartbeatMinutes({
|
resolveReplyHeartbeatIntervalMs({
|
||||||
agent: { heartbeatMinutes: 0 },
|
agent: { heartbeat: { every: "0m" } },
|
||||||
}),
|
}),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
expect(resolveReplyHeartbeatMinutes(cfgBase, 7)).toBe(7);
|
expect(resolveReplyHeartbeatIntervalMs(cfgBase, "7m")).toBe(7 * 60_000);
|
||||||
|
expect(
|
||||||
|
resolveReplyHeartbeatIntervalMs({
|
||||||
|
agent: { heartbeat: { every: "5" } },
|
||||||
|
}),
|
||||||
|
).toBe(5 * 60_000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -506,7 +511,6 @@ describe("runWebHeartbeatOnce", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
setLoadConfigMock(() => ({
|
setLoadConfigMock(() => ({
|
||||||
agent: { heartbeatMinutes: 0.001 },
|
|
||||||
routing: {
|
routing: {
|
||||||
allowFrom: ["+4367"],
|
allowFrom: ["+4367"],
|
||||||
},
|
},
|
||||||
@@ -774,7 +778,7 @@ describe("web auto-reply", () => {
|
|||||||
replyResolver,
|
replyResolver,
|
||||||
runtime,
|
runtime,
|
||||||
controller.signal,
|
controller.signal,
|
||||||
{ replyHeartbeatMinutes: 1, replyHeartbeatNow: true },
|
{ replyHeartbeatEvery: "1m", replyHeartbeatNow: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -833,7 +837,7 @@ describe("web auto-reply", () => {
|
|||||||
replyResolver,
|
replyResolver,
|
||||||
runtime,
|
runtime,
|
||||||
controller.signal,
|
controller.signal,
|
||||||
{ replyHeartbeatMinutes: 10_000 },
|
{ replyHeartbeatEvery: "10000m" },
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
|
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||||
import { waitForever } from "../cli/wait.js";
|
import { waitForever } from "../cli/wait.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
@@ -72,7 +73,7 @@ type WebInboundMsg = Parameters<
|
|||||||
export type WebMonitorTuning = {
|
export type WebMonitorTuning = {
|
||||||
reconnect?: Partial<ReconnectPolicy>;
|
reconnect?: Partial<ReconnectPolicy>;
|
||||||
heartbeatSeconds?: number;
|
heartbeatSeconds?: number;
|
||||||
replyHeartbeatMinutes?: number;
|
replyHeartbeatEvery?: string;
|
||||||
replyHeartbeatNow?: boolean;
|
replyHeartbeatNow?: boolean;
|
||||||
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
||||||
statusSink?: (status: WebProviderStatus) => void;
|
statusSink?: (status: WebProviderStatus) => void;
|
||||||
@@ -81,7 +82,6 @@ export type WebMonitorTuning = {
|
|||||||
const formatDuration = (ms: number) =>
|
const formatDuration = (ms: number) =>
|
||||||
ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`;
|
ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`;
|
||||||
|
|
||||||
const DEFAULT_REPLY_HEARTBEAT_MINUTES = 30;
|
|
||||||
export const HEARTBEAT_PROMPT = "HEARTBEAT";
|
export const HEARTBEAT_PROMPT = "HEARTBEAT";
|
||||||
export { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN };
|
export { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN };
|
||||||
|
|
||||||
@@ -188,14 +188,22 @@ function debugMention(
|
|||||||
return { wasMentioned: result, details };
|
return { wasMentioned: result, details };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveReplyHeartbeatMinutes(
|
export function resolveReplyHeartbeatIntervalMs(
|
||||||
cfg: ReturnType<typeof loadConfig>,
|
cfg: ReturnType<typeof loadConfig>,
|
||||||
overrideMinutes?: number,
|
overrideEvery?: string,
|
||||||
) {
|
) {
|
||||||
const raw = overrideMinutes ?? cfg.agent?.heartbeatMinutes;
|
const raw = overrideEvery ?? cfg.agent?.heartbeat?.every;
|
||||||
if (raw === 0) return null;
|
if (!raw) return null;
|
||||||
if (typeof raw === "number" && raw > 0) return raw;
|
const trimmed = String(raw).trim();
|
||||||
return DEFAULT_REPLY_HEARTBEAT_MINUTES;
|
if (!trimmed) return null;
|
||||||
|
let ms: number;
|
||||||
|
try {
|
||||||
|
ms = parseDurationMs(trimmed, { defaultUnit: "m" });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (ms <= 0) return null;
|
||||||
|
return ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stripHeartbeatToken(raw?: string) {
|
export function stripHeartbeatToken(raw?: string) {
|
||||||
@@ -767,9 +775,9 @@ export async function monitorWebProvider(
|
|||||||
cfg,
|
cfg,
|
||||||
tuning.heartbeatSeconds,
|
tuning.heartbeatSeconds,
|
||||||
);
|
);
|
||||||
const replyHeartbeatMinutes = resolveReplyHeartbeatMinutes(
|
const replyHeartbeatIntervalMs = resolveReplyHeartbeatIntervalMs(
|
||||||
cfg,
|
cfg,
|
||||||
tuning.replyHeartbeatMinutes,
|
tuning.replyHeartbeatEvery,
|
||||||
);
|
);
|
||||||
const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect);
|
const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect);
|
||||||
const mentionConfig = buildMentionConfig(cfg);
|
const mentionConfig = buildMentionConfig(cfg);
|
||||||
@@ -939,7 +947,7 @@ export async function monitorWebProvider(
|
|||||||
let lastInboundMsg: WebInboundMsg | null = null;
|
let lastInboundMsg: WebInboundMsg | null = null;
|
||||||
|
|
||||||
// Watchdog to detect stuck message processing (e.g., event emitter died)
|
// Watchdog to detect stuck message processing (e.g., event emitter died)
|
||||||
// Should be significantly longer than heartbeatMinutes to avoid false positives
|
// Should be significantly longer than the reply heartbeat interval to avoid false positives
|
||||||
const MESSAGE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes without any messages
|
const MESSAGE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes without any messages
|
||||||
const WATCHDOG_CHECK_MS = 60 * 1000; // Check every minute
|
const WATCHDOG_CHECK_MS = 60 * 1000; // Check every minute
|
||||||
|
|
||||||
@@ -1428,7 +1436,7 @@ export async function monitorWebProvider(
|
|||||||
}
|
}
|
||||||
return { status: "skipped", reason: "requests-in-flight" };
|
return { status: "skipped", reason: "requests-in-flight" };
|
||||||
}
|
}
|
||||||
if (!replyHeartbeatMinutes) {
|
if (!replyHeartbeatIntervalMs) {
|
||||||
return { status: "skipped", reason: "disabled" };
|
return { status: "skipped", reason: "disabled" };
|
||||||
}
|
}
|
||||||
let heartbeatInboundMsg = lastInboundMsg;
|
let heartbeatInboundMsg = lastInboundMsg;
|
||||||
@@ -1510,7 +1518,7 @@ export async function monitorWebProvider(
|
|||||||
{
|
{
|
||||||
connectionId,
|
connectionId,
|
||||||
to: heartbeatInboundMsg.from,
|
to: heartbeatInboundMsg.from,
|
||||||
intervalMinutes: replyHeartbeatMinutes,
|
intervalMs: replyHeartbeatIntervalMs,
|
||||||
sessionKey: snapshot.key,
|
sessionKey: snapshot.key,
|
||||||
sessionId: snapshot.entry?.sessionId ?? null,
|
sessionId: snapshot.entry?.sessionId ?? null,
|
||||||
sessionFresh: snapshot.fresh,
|
sessionFresh: snapshot.fresh,
|
||||||
@@ -1635,8 +1643,8 @@ export async function monitorWebProvider(
|
|||||||
|
|
||||||
setReplyHeartbeatWakeHandler(async () => runReplyHeartbeat());
|
setReplyHeartbeatWakeHandler(async () => runReplyHeartbeat());
|
||||||
|
|
||||||
if (replyHeartbeatMinutes && !replyHeartbeatTimer) {
|
if (replyHeartbeatIntervalMs && !replyHeartbeatTimer) {
|
||||||
const intervalMs = replyHeartbeatMinutes * 60_000;
|
const intervalMs = replyHeartbeatIntervalMs;
|
||||||
replyHeartbeatTimer = setInterval(() => {
|
replyHeartbeatTimer = setInterval(() => {
|
||||||
if (!heartbeatsEnabled) return;
|
if (!heartbeatsEnabled) return;
|
||||||
void runReplyHeartbeat();
|
void runReplyHeartbeat();
|
||||||
|
|||||||
Reference in New Issue
Block a user