Add skill ping/health-check system for connection monitoring

Periodic (5-min) onPing() calls verify skill service connections are
healthy. Auth errors stop the skill; network errors update connection
status while keeping the skill running for retry. Skills without onPing
are treated as healthy (backward compatible).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Steven Enamakel
2026-02-10 11:09:20 +05:30
co-authored by Claude Opus 4.6
parent 78d62f182c
commit 1aa7c077d6
6 changed files with 124 additions and 1 deletions
+1 -1
Submodule skills updated: 26341b5297...ceb8d24855
@@ -482,6 +482,9 @@ async fn handle_message(
let params_str = serde_json::to_string(&params).unwrap_or_else(|_| "{}".to_string());
handle_js_call(ctx, "onOAuthComplete", &params_str).await
}
"skill/ping" => {
handle_js_call(ctx, "onPing", "{}").await
}
"oauth/revoked" => {
// Clear credential: set to empty string so it's clearly "disconnected"
let clear_code = r#"(function() {
+99
View File
@@ -15,6 +15,7 @@ import type {
SetupResult,
SkillToolDefinition,
SkillOptionDefinition,
PingResult,
} from "./types";
import { store } from "../../store";
import {
@@ -31,6 +32,8 @@ import {
class SkillManager {
private runtimes = new Map<string, SkillRuntime>();
private pingIntervalId: ReturnType<typeof setInterval> | null = null;
static PING_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
/**
* Get skill-specific load parameters (e.g., session data for Telegram)
@@ -130,6 +133,10 @@ class SkillManager {
const tools = await runtime.listTools();
store.dispatch(setSkillTools({ skillId, tools }));
store.dispatch(setSkillStatus({ skillId, status: "ready" }));
// Fire an initial ping (non-blocking)
this.pingSkill(skillId).catch((err) => {
console.warn(`[SkillManager] Initial ping failed for ${skillId}:`, err);
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
store.dispatch(setSkillError({ skillId, error: msg }));
@@ -337,6 +344,7 @@ class SkillManager {
* Stop all running skills.
*/
async stopAll(): Promise<void> {
this.stopPingLoop();
const ids = Array.from(this.runtimes.keys());
await Promise.all(ids.map((id) => this.stopSkill(id)));
}
@@ -382,6 +390,97 @@ class SkillManager {
}
}
// -----------------------------------------------------------------------
// Ping / health-check
// -----------------------------------------------------------------------
/**
* Start the periodic ping loop. Call once after skills are started.
*/
startPingLoop(): void {
if (this.pingIntervalId) return;
this.pingIntervalId = setInterval(() => {
this.pingAllSkills().catch((err) => {
console.error("[SkillManager] Ping loop error:", err);
});
}, SkillManager.PING_INTERVAL_MS);
}
/**
* Stop the periodic ping loop.
*/
stopPingLoop(): void {
if (this.pingIntervalId) {
clearInterval(this.pingIntervalId);
this.pingIntervalId = null;
}
}
/**
* Ping all ready skills in parallel and handle results.
*/
private async pingAllSkills(): Promise<void> {
const entries: [string, SkillRuntime][] = [];
for (const [id, runtime] of this.runtimes) {
const status = this.getSkillStatus(id);
if (status === "ready" && runtime.isRunning) {
entries.push([id, runtime]);
}
}
if (entries.length === 0) return;
await Promise.allSettled(
entries.map(([id]) => this.pingSkill(id)),
);
}
/**
* Ping a single skill and interpret the result.
* - null / ok:true → healthy, no-op
* - ok:false, errorType:"auth" → stop skill + set error
* - ok:false, errorType:"network" → update connection_status to error (keep running)
*/
private async pingSkill(skillId: string): Promise<void> {
const runtime = this.runtimes.get(skillId);
if (!runtime || !runtime.isRunning) return;
let result: PingResult | null;
try {
result = await runtime.ping();
} catch (err) {
console.warn(`[SkillManager] Ping RPC failed for ${skillId}:`, err);
return;
}
// null means onPing is not implemented → treat as healthy
if (!result || result.ok) return;
console.warn(
`[SkillManager] Ping failed for ${skillId}: ${result.errorType}${result.errorMessage}`,
);
if (result.errorType === "auth") {
store.dispatch(
setSkillError({ skillId, error: result.errorMessage ?? "Auth error" }),
);
await this.stopSkill(skillId);
} else {
// Network error — update state but keep skill running
const currentState =
store.getState().skills.skillStates[skillId] ?? {};
store.dispatch(
setSkillState({
skillId,
state: {
...currentState,
connection_status: "error",
connection_error: result.errorMessage ?? "Connection error",
},
}),
);
}
}
// -----------------------------------------------------------------------
// Reverse RPC handling
// -----------------------------------------------------------------------
+9
View File
@@ -15,6 +15,7 @@ import type {
SetupResult,
SkillToolDefinition,
SkillOptionDefinition,
PingResult,
} from "./types";
export class SkillRuntime {
@@ -143,6 +144,14 @@ export class SkillRuntime {
return this.transport.request("tools/call", { name, arguments: args });
}
/**
* Ping the skill to verify its external service connection is healthy.
* Returns null if the skill doesn't implement onPing (backward compatible).
*/
async ping(): Promise<PingResult | null> {
return this.transport.request<PingResult | null>("skill/ping");
}
/**
* Trigger periodic tick.
*/
+10
View File
@@ -188,6 +188,16 @@ export interface OAuthCredential {
grantedScopes?: string[];
}
// ---------------------------------------------------------------------------
// Ping / Health-Check
// ---------------------------------------------------------------------------
export interface PingResult {
ok: boolean;
errorType?: "network" | "auth";
errorMessage?: string;
}
export interface SkillState {
manifest: SkillManifest;
status: SkillStatus;
+2
View File
@@ -160,6 +160,7 @@ export default function SkillProvider({ children }: { children: ReactNode }) {
// Discover skills from the QuickJS runtime engine
const manifests = await discoverSkills();
await registerAndStart(manifests);
skillManager.startPingLoop();
} catch (err) {
console.error('[SkillProvider] Failed to discover skills:', err);
}
@@ -168,6 +169,7 @@ export default function SkillProvider({ children }: { children: ReactNode }) {
init();
return () => {
skillManager.stopPingLoop();
skillManager.stopAll().catch(console.error);
initRef.current = false;
};