Files
HarborForge.OpenclawPlugin/plugin/index.ts
zhi 3b0ea0ad12 PLG-CAL-004: Implement ScheduledGatewayRestart handling in plugin
- Add state persistence (persistState/restoreState) for recovery after restart
- Add handleScheduledGatewayRestart method that:
  - Persists current scheduler state to disk
  - Sends final heartbeat to backend before shutdown
  - Stops the calendar scheduler (pauses scheduled tasks)
- Add isRestartPending flag to prevent new slot processing during restart
- Add isScheduledGatewayRestart helper to detect restart events
- Update scheduler to detect and handle ScheduledGatewayRestart events
- Add new tools: harborforge_restart_status, harborforge_calendar_pause/resume
- Export isRestartPending and getStateFilePath methods
- Bump plugin version to 0.3.1
2026-04-01 09:41:02 +00:00

561 lines
17 KiB
TypeScript

/**
* HarborForge Plugin for OpenClaw
*
* Provides monitor-related tools and exposes OpenClaw metadata
* for the HarborForge Monitor bridge (via monitor_port).
*
* Also integrates with HarborForge Calendar system to wake agents
* for scheduled tasks (PLG-CAL-002, PLG-CAL-004).
*
* Sidecar architecture has been removed. Telemetry data is now
* served directly by the plugin when Monitor queries via the
* local monitor_port communication path.
*/
import { hostname, freemem, totalmem, uptime, loadavg, platform } from 'os';
import { getLivePluginConfig, type HarborForgeMonitorConfig } from './core/live-config';
import { MonitorBridgeClient, type OpenClawMeta } from './core/monitor-bridge';
import {
createCalendarBridgeClient,
createCalendarScheduler,
CalendarScheduler,
AgentWakeContext,
} from './calendar';
interface PluginAPI {
logger: {
info: (...args: any[]) => void;
error: (...args: any[]) => void;
debug: (...args: any[]) => void;
warn: (...args: any[]) => void;
};
version?: string;
config?: Record<string, unknown>;
pluginConfig?: Record<string, unknown>;
on: (event: string, handler: () => void) => void;
registerTool: (factory: (ctx: any) => any) => void;
/** Spawn a sub-agent with task context (OpenClaw 2.1+) */
spawn?: (options: {
agentId?: string;
task: string;
model?: string;
timeoutSeconds?: number;
}) => Promise<{ sessionId: string; status: string }>;
/** Get current agent status */
getAgentStatus?: () => Promise<{ status: string } | null>;
}
export default {
id: 'harbor-forge',
name: 'HarborForge',
register(api: PluginAPI) {
const logger = api.logger || {
info: (...args: any[]) => console.log('[HarborForge]', ...args),
error: (...args: any[]) => console.error('[HarborForge]', ...args),
debug: (...args: any[]) => console.debug('[HarborForge]', ...args),
warn: (...args: any[]) => console.warn('[HarborForge]', ...args),
};
const baseConfig: HarborForgeMonitorConfig = {
enabled: true,
backendUrl: 'https://monitor.hangman-lab.top',
identifier: '',
reportIntervalSec: 30,
httpFallbackIntervalSec: 60,
logLevel: 'info',
...(api.pluginConfig || {}),
};
function resolveConfig() {
return getLivePluginConfig(api, baseConfig);
}
/**
* Get the monitor bridge client if monitor_port is configured.
* Legacy alias monitorPort is still accepted.
*/
function getBridgeClient(): MonitorBridgeClient | null {
const live = resolveConfig() as any;
const port = live.monitor_port ?? live.monitorPort;
if (!port || port <= 0) return null;
return new MonitorBridgeClient(port);
}
/**
* Collect current system telemetry snapshot.
* This data is exposed to the Monitor bridge when it queries the plugin.
*/
function collectTelemetry() {
const live = resolveConfig();
const load = loadavg();
return {
identifier: live.identifier || hostname(),
platform: platform(),
hostname: hostname(),
uptime: uptime(),
memory: {
total: totalmem(),
free: freemem(),
used: totalmem() - freemem(),
usagePercent: ((totalmem() - freemem()) / totalmem()) * 100,
},
load: {
avg1: load[0],
avg5: load[1],
avg15: load[2],
},
openclaw: {
version: api.version || 'unknown',
pluginVersion: '0.3.1', // Bumped for PLG-CAL-004
},
timestamp: new Date().toISOString(),
};
}
// Periodic metadata push interval handle
let metaPushInterval: ReturnType<typeof setInterval> | null = null;
// Calendar scheduler instance
let calendarScheduler: CalendarScheduler | null = null;
/**
* Push OpenClaw metadata to the Monitor bridge.
* This enriches Monitor heartbeats with OpenClaw version/plugin/agent info.
* Failures are non-fatal — Monitor continues to work without this data.
*/
async function pushMetaToMonitor() {
const bridgeClient = getBridgeClient();
if (!bridgeClient) return;
const meta: OpenClawMeta = {
version: api.version || 'unknown',
plugin_version: '0.3.1',
agents: [], // TODO: populate from api agent list when available
};
const ok = await bridgeClient.pushOpenClawMeta(meta);
if (ok) {
logger.debug('pushed OpenClaw metadata to Monitor bridge');
} else {
logger.debug('Monitor bridge unreachable for metadata push (non-fatal)');
}
}
/**
* Get current agent status from OpenClaw.
* Falls back to querying backend if OpenClaw API unavailable.
*/
async function getAgentStatus(): Promise<'idle' | 'on_call' | 'busy' | 'exhausted' | 'offline' | null> {
// Try OpenClaw API first (if available)
if (api.getAgentStatus) {
try {
const status = await api.getAgentStatus();
if (status?.status) {
return status.status as 'idle' | 'on_call' | 'busy' | 'exhausted' | 'offline';
}
} catch (err) {
logger.debug('Failed to get agent status from OpenClaw API:', err);
}
}
// Fallback: query backend for agent status
const live = resolveConfig();
const agentId = process.env.AGENT_ID || 'unknown';
try {
const response = await fetch(`${live.backendUrl}/calendar/agent/status?agent_id=${agentId}`, {
headers: {
'X-Agent-ID': agentId,
'X-Claw-Identifier': live.identifier || hostname(),
},
});
if (response.ok) {
const data = await response.json();
return data.status;
}
} catch (err) {
logger.debug('Failed to get agent status from backend:', err);
}
return null;
}
/**
* Wake/spawn agent with task context for slot execution.
* This is the callback invoked by CalendarScheduler when a slot is ready.
*/
async function wakeAgent(context: AgentWakeContext): Promise<boolean> {
logger.info(`Waking agent for slot: ${context.taskDescription}`);
try {
// Method 1: Use OpenClaw spawn API if available (preferred)
if (api.spawn) {
const result = await api.spawn({
task: context.prompt,
timeoutSeconds: context.slot.estimated_duration * 60, // Convert to seconds
});
if (result?.sessionId) {
logger.info(`Agent spawned for calendar slot: session=${result.sessionId}`);
// Track session completion
trackSessionCompletion(result.sessionId, context);
return true;
}
}
// Method 2: Send notification/alert to wake agent (fallback)
// This relies on the agent's heartbeat to check for notifications
logger.warn('OpenClaw spawn API not available, using notification fallback');
// Send calendar wakeup notification via backend
const live = resolveConfig();
const agentId = process.env.AGENT_ID || 'unknown';
const notifyResponse = await fetch(`${live.backendUrl}/calendar/agent/notify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Agent-ID': agentId,
'X-Claw-Identifier': live.identifier || hostname(),
},
body: JSON.stringify({
agent_id: agentId,
message: context.prompt,
slot_id: context.slot.id || context.slot.virtual_id,
task_description: context.taskDescription,
}),
});
return notifyResponse.ok;
} catch (err) {
logger.error('Failed to wake agent:', err);
return false;
}
}
/**
* Track session completion and update slot status accordingly.
*/
function trackSessionCompletion(sessionId: string, context: AgentWakeContext): void {
// Poll for session completion (simplified approach)
// In production, this would use webhooks or event streaming
const pollInterval = 30000; // 30 seconds
const maxDuration = context.slot.estimated_duration * 60 * 1000; // Convert to ms
const startTime = Date.now();
const poll = async () => {
if (!calendarScheduler) return;
const elapsed = Date.now() - startTime;
// Check if session is complete (would use actual API in production)
// For now, estimate completion based on duration
if (elapsed >= maxDuration) {
// Assume completion
const actualMinutes = Math.round(elapsed / 60000);
await calendarScheduler.completeCurrentSlot(actualMinutes);
return;
}
// Continue polling
setTimeout(poll, pollInterval);
};
// Start polling
setTimeout(poll, pollInterval);
}
/**
* Initialize and start the calendar scheduler.
*/
function startCalendarScheduler(): void {
const live = resolveConfig();
const agentId = process.env.AGENT_ID || 'unknown';
// Create calendar bridge client
const calendarBridge = createCalendarBridgeClient(
api,
live.backendUrl || 'https://monitor.hangman-lab.top',
agentId
);
// Create and start scheduler
calendarScheduler = createCalendarScheduler({
bridge: calendarBridge,
getAgentStatus,
wakeAgent,
logger,
heartbeatIntervalMs: 60000, // 1 minute
debug: live.logLevel === 'debug',
});
calendarScheduler.start();
logger.info('Calendar scheduler started');
}
/**
* Stop the calendar scheduler.
*/
function stopCalendarScheduler(): void {
if (calendarScheduler) {
calendarScheduler.stop();
calendarScheduler = null;
logger.info('Calendar scheduler stopped');
}
}
api.on('gateway_start', () => {
logger.info('HarborForge plugin active');
// Push metadata to Monitor bridge on startup and periodically.
// Interval aligns with typical Monitor heartbeat cycle (30s).
// If Monitor bridge is unreachable, pushes silently fail.
const live = resolveConfig();
const intervalSec = live.reportIntervalSec || 30;
// Initial push (delayed 2s to let Monitor bridge start)
setTimeout(() => pushMetaToMonitor(), 2000);
metaPushInterval = setInterval(
() => pushMetaToMonitor(),
intervalSec * 1000,
);
// Start calendar scheduler (delayed to let everything initialize)
if (live.enabled !== false) {
setTimeout(() => startCalendarScheduler(), 5000);
}
});
api.on('gateway_stop', () => {
logger.info('HarborForge plugin stopping');
if (metaPushInterval) {
clearInterval(metaPushInterval);
metaPushInterval = null;
}
stopCalendarScheduler();
});
// Tool: plugin status
api.registerTool(() => ({
name: 'harborforge_status',
description: 'Get HarborForge plugin status and current telemetry snapshot',
parameters: {
type: 'object',
properties: {},
},
async execute() {
const live = resolveConfig();
const bridgeClient = getBridgeClient();
let monitorBridge = null;
if (bridgeClient) {
const health = await bridgeClient.health();
monitorBridge = health
? { connected: true, ...health }
: { connected: false, error: 'Monitor bridge unreachable' };
}
// Get calendar scheduler status
const calendarStatus = calendarScheduler ? {
running: calendarScheduler.isRunning(),
processing: calendarScheduler.isProcessing(),
currentSlot: calendarScheduler.getCurrentSlot(),
isRestartPending: calendarScheduler.isRestartPending(),
} : null;
return {
enabled: live.enabled !== false,
config: {
backendUrl: live.backendUrl,
identifier: live.identifier || hostname(),
monitorPort: (live as any).monitor_port ?? (live as any).monitorPort ?? null,
reportIntervalSec: live.reportIntervalSec,
hasApiKey: Boolean(live.apiKey),
},
monitorBridge,
calendar: calendarStatus,
telemetry: collectTelemetry(),
};
},
}));
// Tool: telemetry snapshot (for Monitor bridge queries)
api.registerTool(() => ({
name: 'harborforge_telemetry',
description: 'Get current system telemetry data for HarborForge Monitor',
parameters: {
type: 'object',
properties: {},
},
async execute() {
return collectTelemetry();
},
}));
// Tool: query Monitor bridge for host hardware telemetry
api.registerTool(() => ({
name: 'harborforge_monitor_telemetry',
description: 'Query HarborForge Monitor bridge for host hardware telemetry (CPU, memory, disk, etc.)',
parameters: {
type: 'object',
properties: {},
},
async execute() {
const bridgeClient = getBridgeClient();
if (!bridgeClient) {
return {
error: 'Monitor bridge not configured (monitor_port not set or 0)',
};
}
const data = await bridgeClient.telemetry();
if (!data) {
return {
error: 'Monitor bridge unreachable',
};
}
return data;
},
}));
// Tool: calendar slot management
api.registerTool(() => ({
name: 'harborforge_calendar_status',
description: 'Get current calendar scheduler status and pending slots',
parameters: {
type: 'object',
properties: {},
},
async execute() {
if (!calendarScheduler) {
return { error: 'Calendar scheduler not running' };
}
return {
running: calendarScheduler.isRunning(),
processing: calendarScheduler.isProcessing(),
currentSlot: calendarScheduler.getCurrentSlot(),
state: calendarScheduler.getState(),
isRestartPending: calendarScheduler.isRestartPending(),
stateFilePath: calendarScheduler.getStateFilePath(),
};
},
}));
// Tool: complete current slot (for agent to report completion)
api.registerTool(() => ({
name: 'harborforge_calendar_complete',
description: 'Complete the current calendar slot with actual duration',
parameters: {
type: 'object',
properties: {
actualDurationMinutes: {
type: 'number',
description: 'Actual time spent on the task in minutes',
},
},
required: ['actualDurationMinutes'],
},
async execute(params: { actualDurationMinutes: number }) {
if (!calendarScheduler) {
return { error: 'Calendar scheduler not running' };
}
await calendarScheduler.completeCurrentSlot(params.actualDurationMinutes);
return { success: true, message: 'Slot completed' };
},
}));
// Tool: abort current slot (for agent to report failure)
api.registerTool(() => ({
name: 'harborforge_calendar_abort',
description: 'Abort the current calendar slot',
parameters: {
type: 'object',
properties: {
reason: {
type: 'string',
description: 'Reason for aborting',
},
},
},
async execute(params: { reason?: string }) {
if (!calendarScheduler) {
return { error: 'Calendar scheduler not running' };
}
await calendarScheduler.abortCurrentSlot(params.reason);
return { success: true, message: 'Slot aborted' };
},
}));
// Tool: pause current slot
api.registerTool(() => ({
name: 'harborforge_calendar_pause',
description: 'Pause the current calendar slot',
parameters: {
type: 'object',
properties: {},
},
async execute() {
if (!calendarScheduler) {
return { error: 'Calendar scheduler not running' };
}
await calendarScheduler.pauseCurrentSlot();
return { success: true, message: 'Slot paused' };
},
}));
// Tool: resume current slot
api.registerTool(() => ({
name: 'harborforge_calendar_resume',
description: 'Resume the paused calendar slot',
parameters: {
type: 'object',
properties: {},
},
async execute() {
if (!calendarScheduler) {
return { error: 'Calendar scheduler not running' };
}
await calendarScheduler.resumeCurrentSlot();
return { success: true, message: 'Slot resumed' };
},
}));
// Tool: check ScheduledGatewayRestart status
api.registerTool(() => ({
name: 'harborforge_restart_status',
description: 'Check if a gateway restart is pending (PLG-CAL-004)',
parameters: {
type: 'object',
properties: {},
},
async execute() {
if (!calendarScheduler) {
return { error: 'Calendar scheduler not running' };
}
const isPending = calendarScheduler.isRestartPending();
const stateFilePath = calendarScheduler.getStateFilePath();
return {
isRestartPending: isPending,
stateFilePath: stateFilePath,
message: isPending
? 'A gateway restart has been scheduled. The scheduler has been paused.'
: 'No gateway restart is pending.',
};
},
}));
logger.info('HarborForge plugin registered (id: harbor-forge)');
},
};