From 97021f97c0bda010804224832ffb6cc3f68712a9 Mon Sep 17 00:00:00 2001 From: zhi Date: Wed, 1 Apr 2026 08:45:05 +0000 Subject: [PATCH] PLG-CAL-002: Implement calendar scheduler for agent slot wakeup - Add CalendarScheduler class to manage periodic heartbeat and slot execution - Implement agent wakeup logic when Idle and slots are pending - Handle slot status transitions (attended, ongoing, deferred) - Support both real and virtual slot materialization - Add task context building for different event types (job, system, entertainment) - Integrate scheduler into main plugin index.ts - Add new plugin tools: harborforge_calendar_status, complete, abort --- plugin/calendar/index.d.ts | 18 +- plugin/calendar/index.d.ts.map | 2 +- plugin/calendar/index.js | 18 +- plugin/calendar/index.js.map | 2 +- plugin/calendar/index.ts | 18 +- plugin/calendar/scheduler.d.ts | 184 ++++++++ plugin/calendar/scheduler.d.ts.map | 1 + plugin/calendar/scheduler.js | 526 ++++++++++++++++++++++ plugin/calendar/scheduler.js.map | 1 + plugin/calendar/scheduler.ts | 670 +++++++++++++++++++++++++++++ plugin/index.ts | 274 +++++++++++- 11 files changed, 1698 insertions(+), 16 deletions(-) create mode 100644 plugin/calendar/scheduler.d.ts create mode 100644 plugin/calendar/scheduler.d.ts.map create mode 100644 plugin/calendar/scheduler.js create mode 100644 plugin/calendar/scheduler.js.map create mode 100644 plugin/calendar/scheduler.ts diff --git a/plugin/calendar/index.d.ts b/plugin/calendar/index.d.ts index 7207fc6..583f0fc 100644 --- a/plugin/calendar/index.d.ts +++ b/plugin/calendar/index.d.ts @@ -2,22 +2,32 @@ * HarborForge Calendar — Plugin Module * * PLG-CAL-001: Calendar heartbeat request/response format definition. + * PLG-CAL-002: Plugin-side slot execution scheduler and agent wakeup. * * Exports: * • Types for heartbeat request/response and slot update * • CalendarBridgeClient — HTTP client for backend communication * • createCalendarBridgeClient — factory from plugin API context + * • CalendarScheduler — manages periodic heartbeat and slot execution + * • createCalendarScheduler — factory for scheduler + * • AgentWakeContext — context passed to agent when waking * * Usage in plugin/index.ts: - * import { createCalendarBridgeClient } from './calendar'; + * import { createCalendarBridgeClient, createCalendarScheduler } from './calendar'; * * const agentId = process.env.AGENT_ID || 'unknown'; * const calendar = createCalendarBridgeClient(api, 'https://monitor.hangman-lab.top', agentId); * - * // Inside gateway_start or heartbeat tick: - * const result = await calendar.heartbeat(); - * if (result?.slots.length) { /* handle pending slots /\ } + * const scheduler = createCalendarScheduler({ + * bridge: calendar, + * getAgentStatus: async () => { ... }, + * wakeAgent: async (context) => { ... }, + * logger: api.logger, + * }); + * + * scheduler.start(); */ export * from './types'; export * from './calendar-bridge'; +export * from './scheduler'; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/plugin/calendar/index.d.ts.map b/plugin/calendar/index.d.ts.map index 5762871..1848e1e 100644 --- a/plugin/calendar/index.d.ts.map +++ b/plugin/calendar/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,cAAc,SAAS,CAAC;AACxB,cAAc,mBAAmB,CAAC"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,cAAc,SAAS,CAAC;AACxB,cAAc,mBAAmB,CAAC;AAClC,cAAc,aAAa,CAAC"} \ No newline at end of file diff --git a/plugin/calendar/index.js b/plugin/calendar/index.js index a67b291..914a672 100644 --- a/plugin/calendar/index.js +++ b/plugin/calendar/index.js @@ -3,21 +3,30 @@ * HarborForge Calendar — Plugin Module * * PLG-CAL-001: Calendar heartbeat request/response format definition. + * PLG-CAL-002: Plugin-side slot execution scheduler and agent wakeup. * * Exports: * • Types for heartbeat request/response and slot update * • CalendarBridgeClient — HTTP client for backend communication * • createCalendarBridgeClient — factory from plugin API context + * • CalendarScheduler — manages periodic heartbeat and slot execution + * • createCalendarScheduler — factory for scheduler + * • AgentWakeContext — context passed to agent when waking * * Usage in plugin/index.ts: - * import { createCalendarBridgeClient } from './calendar'; + * import { createCalendarBridgeClient, createCalendarScheduler } from './calendar'; * * const agentId = process.env.AGENT_ID || 'unknown'; * const calendar = createCalendarBridgeClient(api, 'https://monitor.hangman-lab.top', agentId); * - * // Inside gateway_start or heartbeat tick: - * const result = await calendar.heartbeat(); - * if (result?.slots.length) { /* handle pending slots /\ } + * const scheduler = createCalendarScheduler({ + * bridge: calendar, + * getAgentStatus: async () => { ... }, + * wakeAgent: async (context) => { ... }, + * logger: api.logger, + * }); + * + * scheduler.start(); */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; @@ -36,4 +45,5 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) { Object.defineProperty(exports, "__esModule", { value: true }); __exportStar(require("./types"), exports); __exportStar(require("./calendar-bridge"), exports); +__exportStar(require("./scheduler"), exports); //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/plugin/calendar/index.js.map b/plugin/calendar/index.js.map index 88da539..802d83b 100644 --- a/plugin/calendar/index.js.map +++ b/plugin/calendar/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;GAmBG;;;;;;;;;;;;;;;;AAEH,0CAAwB;AACxB,oDAAkC"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;;;;;;;;;;;;;;;;AAEH,0CAAwB;AACxB,oDAAkC;AAClC,8CAA4B"} \ No newline at end of file diff --git a/plugin/calendar/index.ts b/plugin/calendar/index.ts index e25866a..8d2bed7 100644 --- a/plugin/calendar/index.ts +++ b/plugin/calendar/index.ts @@ -2,22 +2,32 @@ * HarborForge Calendar — Plugin Module * * PLG-CAL-001: Calendar heartbeat request/response format definition. + * PLG-CAL-002: Plugin-side slot execution scheduler and agent wakeup. * * Exports: * • Types for heartbeat request/response and slot update * • CalendarBridgeClient — HTTP client for backend communication * • createCalendarBridgeClient — factory from plugin API context + * • CalendarScheduler — manages periodic heartbeat and slot execution + * • createCalendarScheduler — factory for scheduler + * • AgentWakeContext — context passed to agent when waking * * Usage in plugin/index.ts: - * import { createCalendarBridgeClient } from './calendar'; + * import { createCalendarBridgeClient, createCalendarScheduler } from './calendar'; * * const agentId = process.env.AGENT_ID || 'unknown'; * const calendar = createCalendarBridgeClient(api, 'https://monitor.hangman-lab.top', agentId); * - * // Inside gateway_start or heartbeat tick: - * const result = await calendar.heartbeat(); - * if (result?.slots.length) { /* handle pending slots /\ } + * const scheduler = createCalendarScheduler({ + * bridge: calendar, + * getAgentStatus: async () => { ... }, + * wakeAgent: async (context) => { ... }, + * logger: api.logger, + * }); + * + * scheduler.start(); */ export * from './types'; export * from './calendar-bridge'; +export * from './scheduler'; diff --git a/plugin/calendar/scheduler.d.ts b/plugin/calendar/scheduler.d.ts new file mode 100644 index 0000000..8b5129c --- /dev/null +++ b/plugin/calendar/scheduler.d.ts @@ -0,0 +1,184 @@ +/** + * HarborForge Calendar Scheduler + * + * PLG-CAL-002: Plugin-side handling for pending slot execution. + * + * Responsibilities: + * - Run calendar heartbeat every minute + * - Detect when agent is Idle and slots are pending + * - Wake agent with task context + * - Handle slot status transitions (attended, ongoing, deferred) + * - Manage agent status transitions (idle → busy/on_call) + * + * Design reference: NEXT_WAVE_DEV_DIRECTION.md §6 (Agent wakeup mechanism) + */ +import { CalendarBridgeClient } from './calendar-bridge'; +import { CalendarSlotResponse, AgentStatusValue } from './types'; +export interface CalendarSchedulerConfig { + /** Calendar bridge client for backend communication */ + bridge: CalendarBridgeClient; + /** Function to get current agent status from backend */ + getAgentStatus: () => Promise; + /** Function to wake/spawn agent with task context */ + wakeAgent: (context: AgentWakeContext) => Promise; + /** Logger instance */ + logger: { + info: (...args: any[]) => void; + error: (...args: any[]) => void; + debug: (...args: any[]) => void; + warn: (...args: any[]) => void; + }; + /** Heartbeat interval in milliseconds (default: 60000) */ + heartbeatIntervalMs?: number; + /** Enable verbose debug logging */ + debug?: boolean; +} +/** + * Context passed to agent when waking for slot execution. + * This is the payload the agent receives to understand what to do. + */ +export interface AgentWakeContext { + /** The slot to execute */ + slot: CalendarSlotResponse; + /** Human-readable task description */ + taskDescription: string; + /** Prompt/instructions for the agent */ + prompt: string; + /** Whether this is a virtual slot (needs materialization) */ + isVirtual: boolean; +} +/** + * Current execution state tracked by the scheduler. + */ +interface SchedulerState { + /** Whether scheduler is currently running */ + isRunning: boolean; + /** Currently executing slot (null if idle) */ + currentSlot: CalendarSlotResponse | null; + /** Last heartbeat timestamp */ + lastHeartbeatAt: Date | null; + /** Interval handle for cleanup */ + intervalHandle: ReturnType | null; + /** Set of slot IDs that have been deferred in current session */ + deferredSlotIds: Set; + /** Whether agent is currently processing a slot */ + isProcessing: boolean; +} +/** + * CalendarScheduler manages the periodic heartbeat and slot execution lifecycle. + */ +export declare class CalendarScheduler { + private config; + private state; + constructor(config: CalendarSchedulerConfig); + /** + * Start the calendar scheduler. + * Begins periodic heartbeat to check for pending slots. + */ + start(): void; + /** + * Stop the calendar scheduler. + * Cleans up intervals and resets state. + */ + stop(): void; + /** + * Execute a single heartbeat cycle. + * Fetches pending slots and handles execution logic. + */ + runHeartbeat(): Promise; + /** + * Handle slots when agent is not idle. + * Defer all pending slots with priority boost. + */ + private handleNonIdleAgent; + /** + * Handle slots when agent is idle. + * Select highest priority slot and wake agent. + */ + private handleIdleAgent; + /** + * Execute a slot by waking the agent. + */ + private executeSlot; + /** + * Build the wake context for an agent based on slot details. + */ + private buildWakeContext; + /** + * Build prompt for job-type slots. + */ + private buildJobPrompt; + /** + * Build prompt for system event slots. + */ + private buildSystemPrompt; + /** + * Build prompt for entertainment slots. + */ + private buildEntertainmentPrompt; + /** + * Build generic prompt for slots without specific event data. + */ + private buildGenericPrompt; + /** + * Mark a slot as deferred with priority boost. + */ + private deferSlot; + /** + * Revert a slot to not_started status after failed execution attempt. + */ + private revertSlot; + /** + * Complete the current slot execution. + * Call this when the agent finishes the task. + */ + completeCurrentSlot(actualDurationMinutes: number): Promise; + /** + * Abort the current slot execution. + * Call this when the agent cannot complete the task. + */ + abortCurrentSlot(reason?: string): Promise; + /** + * Pause the current slot execution. + * Call this when the agent needs to temporarily pause. + */ + pauseCurrentSlot(): Promise; + /** + * Resume a paused slot. + */ + resumeCurrentSlot(): Promise; + /** + * Get a stable ID for a slot (real or virtual). + */ + private getSlotId; + /** + * Format a Date as ISO time string (HH:MM:SS). + */ + private formatTime; + /** + * Debug logging helper. + */ + private logDebug; + /** + * Get current scheduler state (for introspection). + */ + getState(): Readonly; + /** + * Check if scheduler is running. + */ + isRunning(): boolean; + /** + * Check if currently processing a slot. + */ + isProcessing(): boolean; + /** + * Get the current slot being executed (if any). + */ + getCurrentSlot(): CalendarSlotResponse | null; +} +/** + * Factory function to create a CalendarScheduler from plugin context. + */ +export declare function createCalendarScheduler(config: CalendarSchedulerConfig): CalendarScheduler; +export {}; +//# sourceMappingURL=scheduler.d.ts.map \ No newline at end of file diff --git a/plugin/calendar/scheduler.d.ts.map b/plugin/calendar/scheduler.d.ts.map new file mode 100644 index 0000000..6229d0d --- /dev/null +++ b/plugin/calendar/scheduler.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"scheduler.d.ts","sourceRoot":"","sources":["scheduler.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/plugin/calendar/scheduler.js b/plugin/calendar/scheduler.js new file mode 100644 index 0000000..dfc30fc --- /dev/null +++ b/plugin/calendar/scheduler.js @@ -0,0 +1,526 @@ +"use strict"; +/** + * HarborForge Calendar Scheduler + * + * PLG-CAL-002: Plugin-side handling for pending slot execution. + * + * Responsibilities: + * - Run calendar heartbeat every minute + * - Detect when agent is Idle and slots are pending + * - Wake agent with task context + * - Handle slot status transitions (attended, ongoing, deferred) + * - Manage agent status transitions (idle → busy/on_call) + * + * Design reference: NEXT_WAVE_DEV_DIRECTION.md §6 (Agent wakeup mechanism) + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.CalendarScheduler = void 0; +exports.createCalendarScheduler = createCalendarScheduler; +const types_1 = require("./types"); +/** + * CalendarScheduler manages the periodic heartbeat and slot execution lifecycle. + */ +class CalendarScheduler { + config; + state; + constructor(config) { + this.config = { + heartbeatIntervalMs: 60000, // 1 minute default + debug: false, + ...config, + }; + this.state = { + isRunning: false, + currentSlot: null, + lastHeartbeatAt: null, + intervalHandle: null, + deferredSlotIds: new Set(), + isProcessing: false, + }; + } + /** + * Start the calendar scheduler. + * Begins periodic heartbeat to check for pending slots. + */ + start() { + if (this.state.isRunning) { + this.config.logger.warn('Calendar scheduler already running'); + return; + } + this.state.isRunning = true; + this.config.logger.info('Calendar scheduler started'); + // Run initial heartbeat immediately + this.runHeartbeat(); + // Schedule periodic heartbeats + this.state.intervalHandle = setInterval(() => this.runHeartbeat(), this.config.heartbeatIntervalMs); + } + /** + * Stop the calendar scheduler. + * Cleans up intervals and resets state. + */ + stop() { + this.state.isRunning = false; + if (this.state.intervalHandle) { + clearInterval(this.state.intervalHandle); + this.state.intervalHandle = null; + } + this.config.logger.info('Calendar scheduler stopped'); + } + /** + * Execute a single heartbeat cycle. + * Fetches pending slots and handles execution logic. + */ + async runHeartbeat() { + if (!this.state.isRunning) { + return; + } + this.state.lastHeartbeatAt = new Date(); + try { + // Fetch pending slots from backend + const response = await this.config.bridge.heartbeat(); + if (!response) { + this.logDebug('Heartbeat: backend unreachable'); + return; + } + this.logDebug(`Heartbeat: ${response.slots.length} slots pending, agent_status=${response.agent_status}`); + // If agent is not idle, defer all pending slots + if (response.agent_status !== 'idle') { + await this.handleNonIdleAgent(response.slots, response.agent_status); + return; + } + // Agent is idle - handle pending slots + await this.handleIdleAgent(response.slots); + } + catch (err) { + this.config.logger.error('Heartbeat error:', err); + } + } + /** + * Handle slots when agent is not idle. + * Defer all pending slots with priority boost. + */ + async handleNonIdleAgent(slots, agentStatus) { + if (slots.length === 0) { + return; + } + this.config.logger.info(`Agent not idle (status=${agentStatus}), deferring ${slots.length} slot(s)`); + for (const slot of slots) { + const slotId = this.getSlotId(slot); + // Skip if already deferred this session + if (this.state.deferredSlotIds.has(slotId)) { + continue; + } + // Mark slot as deferred with priority boost (+1) + await this.deferSlot(slot); + this.state.deferredSlotIds.add(slotId); + } + } + /** + * Handle slots when agent is idle. + * Select highest priority slot and wake agent. + */ + async handleIdleAgent(slots) { + if (slots.length === 0) { + return; + } + // Filter out already deferred slots in this session + const eligibleSlots = slots.filter(s => !this.state.deferredSlotIds.has(this.getSlotId(s))); + if (eligibleSlots.length === 0) { + this.logDebug('All pending slots have been deferred this session'); + return; + } + // Select highest priority slot (backend already sorts by priority DESC) + const [selectedSlot, ...remainingSlots] = eligibleSlots; + this.config.logger.info(`Selected slot for execution: id=${this.getSlotId(selectedSlot)}, ` + + `type=${selectedSlot.slot_type}, priority=${selectedSlot.priority}`); + // Mark remaining slots as deferred + for (const slot of remainingSlots) { + await this.deferSlot(slot); + this.state.deferredSlotIds.add(this.getSlotId(slot)); + } + // Wake agent to execute selected slot + await this.executeSlot(selectedSlot); + } + /** + * Execute a slot by waking the agent. + */ + async executeSlot(slot) { + if (this.state.isProcessing) { + this.config.logger.warn('Already processing a slot, deferring new slot'); + await this.deferSlot(slot); + return; + } + this.state.isProcessing = true; + this.state.currentSlot = slot; + try { + // Mark slot as attended and ongoing before waking agent + const update = { + status: types_1.SlotStatus.ONGOING, + started_at: this.formatTime(new Date()), + }; + let updateSuccess; + if (slot.id) { + updateSuccess = await this.config.bridge.updateSlot(slot.id, update); + } + else if (slot.virtual_id) { + const updated = await this.config.bridge.updateVirtualSlot(slot.virtual_id, update); + updateSuccess = updated !== null; + // Update slot reference if materialized + if (updated) { + this.state.currentSlot = updated; + } + } + else { + updateSuccess = false; + } + if (!updateSuccess) { + this.config.logger.error('Failed to update slot status before execution'); + this.state.isProcessing = false; + this.state.currentSlot = null; + return; + } + // Report agent status change to backend + const newAgentStatus = slot.slot_type === 'on_call' ? 'on_call' : 'busy'; + await this.config.bridge.reportAgentStatus({ status: newAgentStatus }); + // Build wake context for agent + const wakeContext = this.buildWakeContext(slot); + // Wake the agent + const wakeSuccess = await this.config.wakeAgent(wakeContext); + if (!wakeSuccess) { + this.config.logger.error('Failed to wake agent for slot execution'); + // Revert slot to not_started status + await this.revertSlot(slot); + await this.config.bridge.reportAgentStatus({ status: 'idle' }); + } + // Note: isProcessing remains true until agent signals completion + // This is handled by external completion callback + } + catch (err) { + this.config.logger.error('Error executing slot:', err); + this.state.isProcessing = false; + this.state.currentSlot = null; + } + } + /** + * Build the wake context for an agent based on slot details. + */ + buildWakeContext(slot) { + const isVirtual = slot.virtual_id !== null; + const slotId = this.getSlotId(slot); + // Build task description based on event type + let taskDescription; + let prompt; + if (slot.event_type === 'job' && slot.event_data) { + const jobData = slot.event_data; + taskDescription = `${jobData.type} ${jobData.code}`; + prompt = this.buildJobPrompt(slot, jobData); + } + else if (slot.event_type === 'system_event' && slot.event_data) { + const sysData = slot.event_data; + taskDescription = `System Event: ${sysData.event}`; + prompt = this.buildSystemPrompt(slot, sysData); + } + else if (slot.event_type === 'entertainment') { + taskDescription = 'Entertainment slot'; + prompt = this.buildEntertainmentPrompt(slot); + } + else { + taskDescription = `Generic ${slot.slot_type} slot`; + prompt = this.buildGenericPrompt(slot); + } + return { + slot, + taskDescription, + prompt, + isVirtual, + }; + } + /** + * Build prompt for job-type slots. + */ + buildJobPrompt(slot, jobData) { + const duration = slot.estimated_duration; + const type = jobData.type; + const code = jobData.code; + return `You have a scheduled ${type} job to work on. + +Task Code: ${code} +Estimated Duration: ${duration} minutes +Slot Type: ${slot.slot_type} +Priority: ${slot.priority} + +Please focus on this task for the allocated time. When you finish or need to pause, +report your progress back to the calendar system. + +Working sessions: ${jobData.working_sessions?.join(', ') || 'none recorded'} + +Start working on ${code} now.`; + } + /** + * Build prompt for system event slots. + */ + buildSystemPrompt(slot, sysData) { + switch (sysData.event) { + case 'ScheduleToday': + return `System Event: Schedule Today + +Please review today's calendar and schedule any pending tasks or planning activities. +Estimated time: ${slot.estimated_duration} minutes. + +Check your calendar and plan the day's work.`; + case 'SummaryToday': + return `System Event: Daily Summary + +Please provide a summary of today's activities and progress. +Estimated time: ${slot.estimated_duration} minutes. + +Review what was accomplished and prepare end-of-day notes.`; + case 'ScheduledGatewayRestart': + return `System Event: Scheduled Gateway Restart + +The OpenClaw gateway is scheduled to restart soon. +Please: +1. Persist any important state +2. Complete or gracefully pause current tasks +3. Prepare for restart + +Time remaining: ${slot.estimated_duration} minutes.`; + default: + return `System Event: ${sysData.event} + +A system event has been scheduled. Please handle accordingly. +Estimated time: ${slot.estimated_duration} minutes.`; + } + } + /** + * Build prompt for entertainment slots. + */ + buildEntertainmentPrompt(slot) { + return `Scheduled Entertainment Break + +Duration: ${slot.estimated_duration} minutes + +Take a break and enjoy some leisure time. This slot is reserved for non-work activities + to help maintain work-life balance.`; + } + /** + * Build generic prompt for slots without specific event data. + */ + buildGenericPrompt(slot) { + return `Scheduled Calendar Slot + +Type: ${slot.slot_type} +Duration: ${slot.estimated_duration} minutes +Priority: ${slot.priority} + +Please use this time for the scheduled activity.`; + } + /** + * Mark a slot as deferred with priority boost. + */ + async deferSlot(slot) { + const update = { + status: types_1.SlotStatus.DEFERRED, + }; + try { + if (slot.id) { + await this.config.bridge.updateSlot(slot.id, update); + } + else if (slot.virtual_id) { + await this.config.bridge.updateVirtualSlot(slot.virtual_id, update); + } + this.logDebug(`Deferred slot: ${this.getSlotId(slot)}`); + } + catch (err) { + this.config.logger.error('Failed to defer slot:', err); + } + } + /** + * Revert a slot to not_started status after failed execution attempt. + */ + async revertSlot(slot) { + const update = { + status: types_1.SlotStatus.NOT_STARTED, + started_at: undefined, + }; + try { + if (slot.id) { + await this.config.bridge.updateSlot(slot.id, update); + } + else if (slot.virtual_id) { + await this.config.bridge.updateVirtualSlot(slot.virtual_id, update); + } + } + catch (err) { + this.config.logger.error('Failed to revert slot:', err); + } + } + /** + * Complete the current slot execution. + * Call this when the agent finishes the task. + */ + async completeCurrentSlot(actualDurationMinutes) { + if (!this.state.currentSlot) { + this.config.logger.warn('No current slot to complete'); + return; + } + const slot = this.state.currentSlot; + const update = { + status: types_1.SlotStatus.FINISHED, + actual_duration: actualDurationMinutes, + }; + try { + if (slot.id) { + await this.config.bridge.updateSlot(slot.id, update); + } + else if (slot.virtual_id) { + await this.config.bridge.updateVirtualSlot(slot.virtual_id, update); + } + // Report agent back to idle + await this.config.bridge.reportAgentStatus({ status: 'idle' }); + this.config.logger.info(`Completed slot ${this.getSlotId(slot)}, actual_duration=${actualDurationMinutes}min`); + } + catch (err) { + this.config.logger.error('Failed to complete slot:', err); + } + finally { + this.state.isProcessing = false; + this.state.currentSlot = null; + } + } + /** + * Abort the current slot execution. + * Call this when the agent cannot complete the task. + */ + async abortCurrentSlot(reason) { + if (!this.state.currentSlot) { + this.config.logger.warn('No current slot to abort'); + return; + } + const slot = this.state.currentSlot; + const update = { + status: types_1.SlotStatus.ABORTED, + }; + try { + if (slot.id) { + await this.config.bridge.updateSlot(slot.id, update); + } + else if (slot.virtual_id) { + await this.config.bridge.updateVirtualSlot(slot.virtual_id, update); + } + // Report agent back to idle + await this.config.bridge.reportAgentStatus({ status: 'idle' }); + this.config.logger.info(`Aborted slot ${this.getSlotId(slot)}${reason ? `: ${reason}` : ''}`); + } + catch (err) { + this.config.logger.error('Failed to abort slot:', err); + } + finally { + this.state.isProcessing = false; + this.state.currentSlot = null; + } + } + /** + * Pause the current slot execution. + * Call this when the agent needs to temporarily pause. + */ + async pauseCurrentSlot() { + if (!this.state.currentSlot) { + this.config.logger.warn('No current slot to pause'); + return; + } + const slot = this.state.currentSlot; + const update = { + status: types_1.SlotStatus.PAUSED, + }; + try { + if (slot.id) { + await this.config.bridge.updateSlot(slot.id, update); + } + else if (slot.virtual_id) { + await this.config.bridge.updateVirtualSlot(slot.virtual_id, update); + } + this.config.logger.info(`Paused slot ${this.getSlotId(slot)}`); + } + catch (err) { + this.config.logger.error('Failed to pause slot:', err); + } + } + /** + * Resume a paused slot. + */ + async resumeCurrentSlot() { + if (!this.state.currentSlot) { + this.config.logger.warn('No current slot to resume'); + return; + } + const slot = this.state.currentSlot; + const update = { + status: types_1.SlotStatus.ONGOING, + }; + try { + if (slot.id) { + await this.config.bridge.updateSlot(slot.id, update); + } + else if (slot.virtual_id) { + await this.config.bridge.updateVirtualSlot(slot.virtual_id, update); + } + this.config.logger.info(`Resumed slot ${this.getSlotId(slot)}`); + } + catch (err) { + this.config.logger.error('Failed to resume slot:', err); + } + } + /** + * Get a stable ID for a slot (real or virtual). + */ + getSlotId(slot) { + return slot.id?.toString() || slot.virtual_id || 'unknown'; + } + /** + * Format a Date as ISO time string (HH:MM:SS). + */ + formatTime(date) { + return date.toTimeString().split(' ')[0]; + } + /** + * Debug logging helper. + */ + logDebug(message) { + if (this.config.debug) { + this.config.logger.debug(`[CalendarScheduler] ${message}`); + } + } + /** + * Get current scheduler state (for introspection). + */ + getState() { + return { ...this.state }; + } + /** + * Check if scheduler is running. + */ + isRunning() { + return this.state.isRunning; + } + /** + * Check if currently processing a slot. + */ + isProcessing() { + return this.state.isProcessing; + } + /** + * Get the current slot being executed (if any). + */ + getCurrentSlot() { + return this.state.currentSlot; + } +} +exports.CalendarScheduler = CalendarScheduler; +/** + * Factory function to create a CalendarScheduler from plugin context. + */ +function createCalendarScheduler(config) { + return new CalendarScheduler(config); +} +//# sourceMappingURL=scheduler.js.map \ No newline at end of file diff --git a/plugin/calendar/scheduler.js.map b/plugin/calendar/scheduler.js.map new file mode 100644 index 0000000..d14968f --- /dev/null +++ b/plugin/calendar/scheduler.js.map @@ -0,0 +1 @@ +{"version":3,"file":"scheduler.js","sourceRoot":"","sources":["scheduler.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/plugin/calendar/scheduler.ts b/plugin/calendar/scheduler.ts new file mode 100644 index 0000000..e243c55 --- /dev/null +++ b/plugin/calendar/scheduler.ts @@ -0,0 +1,670 @@ +/** + * HarborForge Calendar Scheduler + * + * PLG-CAL-002: Plugin-side handling for pending slot execution. + * + * Responsibilities: + * - Run calendar heartbeat every minute + * - Detect when agent is Idle and slots are pending + * - Wake agent with task context + * - Handle slot status transitions (attended, ongoing, deferred) + * - Manage agent status transitions (idle → busy/on_call) + * + * Design reference: NEXT_WAVE_DEV_DIRECTION.md §6 (Agent wakeup mechanism) + */ + +import { + CalendarBridgeClient, +} from './calendar-bridge'; +import { + CalendarSlotResponse, + SlotStatus, + AgentStatusValue, + SlotAgentUpdate, + CalendarEventDataJob, + CalendarEventDataSystemEvent, +} from './types'; + +export interface CalendarSchedulerConfig { + /** Calendar bridge client for backend communication */ + bridge: CalendarBridgeClient; + /** Function to get current agent status from backend */ + getAgentStatus: () => Promise; + /** Function to wake/spawn agent with task context */ + wakeAgent: (context: AgentWakeContext) => Promise; + /** Logger instance */ + logger: { + info: (...args: any[]) => void; + error: (...args: any[]) => void; + debug: (...args: any[]) => void; + warn: (...args: any[]) => void; + }; + /** Heartbeat interval in milliseconds (default: 60000) */ + heartbeatIntervalMs?: number; + /** Enable verbose debug logging */ + debug?: boolean; +} + +/** + * Context passed to agent when waking for slot execution. + * This is the payload the agent receives to understand what to do. + */ +export interface AgentWakeContext { + /** The slot to execute */ + slot: CalendarSlotResponse; + /** Human-readable task description */ + taskDescription: string; + /** Prompt/instructions for the agent */ + prompt: string; + /** Whether this is a virtual slot (needs materialization) */ + isVirtual: boolean; +} + +/** + * Current execution state tracked by the scheduler. + */ +interface SchedulerState { + /** Whether scheduler is currently running */ + isRunning: boolean; + /** Currently executing slot (null if idle) */ + currentSlot: CalendarSlotResponse | null; + /** Last heartbeat timestamp */ + lastHeartbeatAt: Date | null; + /** Interval handle for cleanup */ + intervalHandle: ReturnType | null; + /** Set of slot IDs that have been deferred in current session */ + deferredSlotIds: Set; + /** Whether agent is currently processing a slot */ + isProcessing: boolean; +} + +/** + * CalendarScheduler manages the periodic heartbeat and slot execution lifecycle. + */ +export class CalendarScheduler { + private config: Required; + private state: SchedulerState; + + constructor(config: CalendarSchedulerConfig) { + this.config = { + heartbeatIntervalMs: 60000, // 1 minute default + debug: false, + ...config, + }; + this.state = { + isRunning: false, + currentSlot: null, + lastHeartbeatAt: null, + intervalHandle: null, + deferredSlotIds: new Set(), + isProcessing: false, + }; + } + + /** + * Start the calendar scheduler. + * Begins periodic heartbeat to check for pending slots. + */ + start(): void { + if (this.state.isRunning) { + this.config.logger.warn('Calendar scheduler already running'); + return; + } + + this.state.isRunning = true; + this.config.logger.info('Calendar scheduler started'); + + // Run initial heartbeat immediately + this.runHeartbeat(); + + // Schedule periodic heartbeats + this.state.intervalHandle = setInterval( + () => this.runHeartbeat(), + this.config.heartbeatIntervalMs + ); + } + + /** + * Stop the calendar scheduler. + * Cleans up intervals and resets state. + */ + stop(): void { + this.state.isRunning = false; + + if (this.state.intervalHandle) { + clearInterval(this.state.intervalHandle); + this.state.intervalHandle = null; + } + + this.config.logger.info('Calendar scheduler stopped'); + } + + /** + * Execute a single heartbeat cycle. + * Fetches pending slots and handles execution logic. + */ + async runHeartbeat(): Promise { + if (!this.state.isRunning) { + return; + } + + this.state.lastHeartbeatAt = new Date(); + + try { + // Fetch pending slots from backend + const response = await this.config.bridge.heartbeat(); + + if (!response) { + this.logDebug('Heartbeat: backend unreachable'); + return; + } + + this.logDebug(`Heartbeat: ${response.slots.length} slots pending, agent_status=${response.agent_status}`); + + // If agent is not idle, defer all pending slots + if (response.agent_status !== 'idle') { + await this.handleNonIdleAgent(response.slots, response.agent_status); + return; + } + + // Agent is idle - handle pending slots + await this.handleIdleAgent(response.slots); + + } catch (err) { + this.config.logger.error('Heartbeat error:', err); + } + } + + /** + * Handle slots when agent is not idle. + * Defer all pending slots with priority boost. + */ + private async handleNonIdleAgent( + slots: CalendarSlotResponse[], + agentStatus: AgentStatusValue + ): Promise { + if (slots.length === 0) { + return; + } + + this.config.logger.info( + `Agent not idle (status=${agentStatus}), deferring ${slots.length} slot(s)` + ); + + for (const slot of slots) { + const slotId = this.getSlotId(slot); + + // Skip if already deferred this session + if (this.state.deferredSlotIds.has(slotId)) { + continue; + } + + // Mark slot as deferred with priority boost (+1) + await this.deferSlot(slot); + this.state.deferredSlotIds.add(slotId); + } + } + + /** + * Handle slots when agent is idle. + * Select highest priority slot and wake agent. + */ + private async handleIdleAgent(slots: CalendarSlotResponse[]): Promise { + if (slots.length === 0) { + return; + } + + // Filter out already deferred slots in this session + const eligibleSlots = slots.filter( + s => !this.state.deferredSlotIds.has(this.getSlotId(s)) + ); + + if (eligibleSlots.length === 0) { + this.logDebug('All pending slots have been deferred this session'); + return; + } + + // Select highest priority slot (backend already sorts by priority DESC) + const [selectedSlot, ...remainingSlots] = eligibleSlots; + + this.config.logger.info( + `Selected slot for execution: id=${this.getSlotId(selectedSlot)}, ` + + `type=${selectedSlot.slot_type}, priority=${selectedSlot.priority}` + ); + + // Mark remaining slots as deferred + for (const slot of remainingSlots) { + await this.deferSlot(slot); + this.state.deferredSlotIds.add(this.getSlotId(slot)); + } + + // Wake agent to execute selected slot + await this.executeSlot(selectedSlot); + } + + /** + * Execute a slot by waking the agent. + */ + private async executeSlot(slot: CalendarSlotResponse): Promise { + if (this.state.isProcessing) { + this.config.logger.warn('Already processing a slot, deferring new slot'); + await this.deferSlot(slot); + return; + } + + this.state.isProcessing = true; + this.state.currentSlot = slot; + + try { + // Mark slot as attended and ongoing before waking agent + const update: SlotAgentUpdate = { + status: SlotStatus.ONGOING, + started_at: this.formatTime(new Date()), + }; + + let updateSuccess: boolean; + if (slot.id) { + updateSuccess = await this.config.bridge.updateSlot(slot.id, update); + } else if (slot.virtual_id) { + const updated = await this.config.bridge.updateVirtualSlot(slot.virtual_id, update); + updateSuccess = updated !== null; + // Update slot reference if materialized + if (updated) { + this.state.currentSlot = updated; + } + } else { + updateSuccess = false; + } + + if (!updateSuccess) { + this.config.logger.error('Failed to update slot status before execution'); + this.state.isProcessing = false; + this.state.currentSlot = null; + return; + } + + // Report agent status change to backend + const newAgentStatus = slot.slot_type === 'on_call' ? 'on_call' : 'busy'; + await this.config.bridge.reportAgentStatus({ status: newAgentStatus }); + + // Build wake context for agent + const wakeContext = this.buildWakeContext(slot); + + // Wake the agent + const wakeSuccess = await this.config.wakeAgent(wakeContext); + + if (!wakeSuccess) { + this.config.logger.error('Failed to wake agent for slot execution'); + // Revert slot to not_started status + await this.revertSlot(slot); + await this.config.bridge.reportAgentStatus({ status: 'idle' }); + } + + // Note: isProcessing remains true until agent signals completion + // This is handled by external completion callback + + } catch (err) { + this.config.logger.error('Error executing slot:', err); + this.state.isProcessing = false; + this.state.currentSlot = null; + } + } + + /** + * Build the wake context for an agent based on slot details. + */ + private buildWakeContext(slot: CalendarSlotResponse): AgentWakeContext { + const isVirtual = slot.virtual_id !== null; + const slotId = this.getSlotId(slot); + + // Build task description based on event type + let taskDescription: string; + let prompt: string; + + if (slot.event_type === 'job' && slot.event_data) { + const jobData = slot.event_data as CalendarEventDataJob; + taskDescription = `${jobData.type} ${jobData.code}`; + prompt = this.buildJobPrompt(slot, jobData); + } else if (slot.event_type === 'system_event' && slot.event_data) { + const sysData = slot.event_data as CalendarEventDataSystemEvent; + taskDescription = `System Event: ${sysData.event}`; + prompt = this.buildSystemPrompt(slot, sysData); + } else if (slot.event_type === 'entertainment') { + taskDescription = 'Entertainment slot'; + prompt = this.buildEntertainmentPrompt(slot); + } else { + taskDescription = `Generic ${slot.slot_type} slot`; + prompt = this.buildGenericPrompt(slot); + } + + return { + slot, + taskDescription, + prompt, + isVirtual, + }; + } + + /** + * Build prompt for job-type slots. + */ + private buildJobPrompt(slot: CalendarSlotResponse, jobData: CalendarEventDataJob): string { + const duration = slot.estimated_duration; + const type = jobData.type; + const code = jobData.code; + + return `You have a scheduled ${type} job to work on. + +Task Code: ${code} +Estimated Duration: ${duration} minutes +Slot Type: ${slot.slot_type} +Priority: ${slot.priority} + +Please focus on this task for the allocated time. When you finish or need to pause, +report your progress back to the calendar system. + +Working sessions: ${jobData.working_sessions?.join(', ') || 'none recorded'} + +Start working on ${code} now.`; + } + + /** + * Build prompt for system event slots. + */ + private buildSystemPrompt( + slot: CalendarSlotResponse, + sysData: CalendarEventDataSystemEvent + ): string { + switch (sysData.event) { + case 'ScheduleToday': + return `System Event: Schedule Today + +Please review today's calendar and schedule any pending tasks or planning activities. +Estimated time: ${slot.estimated_duration} minutes. + +Check your calendar and plan the day's work.`; + + case 'SummaryToday': + return `System Event: Daily Summary + +Please provide a summary of today's activities and progress. +Estimated time: ${slot.estimated_duration} minutes. + +Review what was accomplished and prepare end-of-day notes.`; + + case 'ScheduledGatewayRestart': + return `System Event: Scheduled Gateway Restart + +The OpenClaw gateway is scheduled to restart soon. +Please: +1. Persist any important state +2. Complete or gracefully pause current tasks +3. Prepare for restart + +Time remaining: ${slot.estimated_duration} minutes.`; + + default: + return `System Event: ${sysData.event} + +A system event has been scheduled. Please handle accordingly. +Estimated time: ${slot.estimated_duration} minutes.`; + } + } + + /** + * Build prompt for entertainment slots. + */ + private buildEntertainmentPrompt(slot: CalendarSlotResponse): string { + return `Scheduled Entertainment Break + +Duration: ${slot.estimated_duration} minutes + +Take a break and enjoy some leisure time. This slot is reserved for non-work activities + to help maintain work-life balance.`; + } + + /** + * Build generic prompt for slots without specific event data. + */ + private buildGenericPrompt(slot: CalendarSlotResponse): string { + return `Scheduled Calendar Slot + +Type: ${slot.slot_type} +Duration: ${slot.estimated_duration} minutes +Priority: ${slot.priority} + +Please use this time for the scheduled activity.`; + } + + /** + * Mark a slot as deferred with priority boost. + */ + private async deferSlot(slot: CalendarSlotResponse): Promise { + const update: SlotAgentUpdate = { + status: SlotStatus.DEFERRED, + }; + + try { + if (slot.id) { + await this.config.bridge.updateSlot(slot.id, update); + } else if (slot.virtual_id) { + await this.config.bridge.updateVirtualSlot(slot.virtual_id, update); + } + this.logDebug(`Deferred slot: ${this.getSlotId(slot)}`); + } catch (err) { + this.config.logger.error('Failed to defer slot:', err); + } + } + + /** + * Revert a slot to not_started status after failed execution attempt. + */ + private async revertSlot(slot: CalendarSlotResponse): Promise { + const update: SlotAgentUpdate = { + status: SlotStatus.NOT_STARTED, + started_at: undefined, + }; + + try { + if (slot.id) { + await this.config.bridge.updateSlot(slot.id, update); + } else if (slot.virtual_id) { + await this.config.bridge.updateVirtualSlot(slot.virtual_id, update); + } + } catch (err) { + this.config.logger.error('Failed to revert slot:', err); + } + } + + /** + * Complete the current slot execution. + * Call this when the agent finishes the task. + */ + async completeCurrentSlot(actualDurationMinutes: number): Promise { + if (!this.state.currentSlot) { + this.config.logger.warn('No current slot to complete'); + return; + } + + const slot = this.state.currentSlot; + const update: SlotAgentUpdate = { + status: SlotStatus.FINISHED, + actual_duration: actualDurationMinutes, + }; + + try { + if (slot.id) { + await this.config.bridge.updateSlot(slot.id, update); + } else if (slot.virtual_id) { + await this.config.bridge.updateVirtualSlot(slot.virtual_id, update); + } + + // Report agent back to idle + await this.config.bridge.reportAgentStatus({ status: 'idle' }); + + this.config.logger.info( + `Completed slot ${this.getSlotId(slot)}, actual_duration=${actualDurationMinutes}min` + ); + + } catch (err) { + this.config.logger.error('Failed to complete slot:', err); + } finally { + this.state.isProcessing = false; + this.state.currentSlot = null; + } + } + + /** + * Abort the current slot execution. + * Call this when the agent cannot complete the task. + */ + async abortCurrentSlot(reason?: string): Promise { + if (!this.state.currentSlot) { + this.config.logger.warn('No current slot to abort'); + return; + } + + const slot = this.state.currentSlot; + const update: SlotAgentUpdate = { + status: SlotStatus.ABORTED, + }; + + try { + if (slot.id) { + await this.config.bridge.updateSlot(slot.id, update); + } else if (slot.virtual_id) { + await this.config.bridge.updateVirtualSlot(slot.virtual_id, update); + } + + // Report agent back to idle + await this.config.bridge.reportAgentStatus({ status: 'idle' }); + + this.config.logger.info( + `Aborted slot ${this.getSlotId(slot)}${reason ? `: ${reason}` : ''}` + ); + + } catch (err) { + this.config.logger.error('Failed to abort slot:', err); + } finally { + this.state.isProcessing = false; + this.state.currentSlot = null; + } + } + + /** + * Pause the current slot execution. + * Call this when the agent needs to temporarily pause. + */ + async pauseCurrentSlot(): Promise { + if (!this.state.currentSlot) { + this.config.logger.warn('No current slot to pause'); + return; + } + + const slot = this.state.currentSlot; + const update: SlotAgentUpdate = { + status: SlotStatus.PAUSED, + }; + + try { + if (slot.id) { + await this.config.bridge.updateSlot(slot.id, update); + } else if (slot.virtual_id) { + await this.config.bridge.updateVirtualSlot(slot.virtual_id, update); + } + + this.config.logger.info(`Paused slot ${this.getSlotId(slot)}`); + + } catch (err) { + this.config.logger.error('Failed to pause slot:', err); + } + } + + /** + * Resume a paused slot. + */ + async resumeCurrentSlot(): Promise { + if (!this.state.currentSlot) { + this.config.logger.warn('No current slot to resume'); + return; + } + + const slot = this.state.currentSlot; + const update: SlotAgentUpdate = { + status: SlotStatus.ONGOING, + }; + + try { + if (slot.id) { + await this.config.bridge.updateSlot(slot.id, update); + } else if (slot.virtual_id) { + await this.config.bridge.updateVirtualSlot(slot.virtual_id, update); + } + + this.config.logger.info(`Resumed slot ${this.getSlotId(slot)}`); + + } catch (err) { + this.config.logger.error('Failed to resume slot:', err); + } + } + + /** + * Get a stable ID for a slot (real or virtual). + */ + private getSlotId(slot: CalendarSlotResponse): string { + return slot.id?.toString() || slot.virtual_id || 'unknown'; + } + + /** + * Format a Date as ISO time string (HH:MM:SS). + */ + private formatTime(date: Date): string { + return date.toTimeString().split(' ')[0]; + } + + /** + * Debug logging helper. + */ + private logDebug(message: string): void { + if (this.config.debug) { + this.config.logger.debug(`[CalendarScheduler] ${message}`); + } + } + + /** + * Get current scheduler state (for introspection). + */ + getState(): Readonly { + return { ...this.state }; + } + + /** + * Check if scheduler is running. + */ + isRunning(): boolean { + return this.state.isRunning; + } + + /** + * Check if currently processing a slot. + */ + isProcessing(): boolean { + return this.state.isProcessing; + } + + /** + * Get the current slot being executed (if any). + */ + getCurrentSlot(): CalendarSlotResponse | null { + return this.state.currentSlot; + } +} + +/** + * Factory function to create a CalendarScheduler from plugin context. + */ +export function createCalendarScheduler( + config: CalendarSchedulerConfig +): CalendarScheduler { + return new CalendarScheduler(config); +} diff --git a/plugin/index.ts b/plugin/index.ts index abef652..19a1b33 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -4,6 +4,9 @@ * 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). + * * Sidecar architecture has been removed. Telemetry data is now * served directly by the plugin when Monitor queries via the * local monitor_port communication path. @@ -11,6 +14,12 @@ 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: { @@ -24,6 +33,15 @@ interface PluginAPI { pluginConfig?: Record; 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 { @@ -87,7 +105,7 @@ export default { }, openclaw: { version: api.version || 'unknown', - pluginVersion: '0.2.0', + pluginVersion: '0.3.0', }, timestamp: new Date().toISOString(), }; @@ -96,6 +114,9 @@ export default { // Periodic metadata push interval handle let metaPushInterval: ReturnType | 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. @@ -107,7 +128,7 @@ export default { const meta: OpenClawMeta = { version: api.version || 'unknown', - plugin_version: '0.2.0', + plugin_version: '0.3.0', agents: [], // TODO: populate from api agent list when available }; @@ -119,6 +140,170 @@ export default { } } + /** + * 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 { + 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'); @@ -135,14 +320,22 @@ export default { () => 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 @@ -165,6 +358,13 @@ export default { : { connected: false, error: 'Monitor bridge unreachable' }; } + // Get calendar scheduler status + const calendarStatus = calendarScheduler ? { + running: calendarScheduler.isRunning(), + processing: calendarScheduler.isProcessing(), + currentSlot: calendarScheduler.getCurrentSlot(), + } : null; + return { enabled: live.enabled !== false, config: { @@ -175,6 +375,7 @@ export default { hasApiKey: Boolean(live.apiKey), }, monitorBridge, + calendar: calendarStatus, telemetry: collectTelemetry(), }; }, @@ -220,6 +421,75 @@ export default { }, })); + // 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(), + }; + }, + })); + + // 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' }; + }, + })); + logger.info('HarborForge plugin registered (id: harbor-forge)'); }, };