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
This commit is contained in:
zhi
2026-04-01 09:41:02 +00:00
parent 24c4a7ad14
commit 3b0ea0ad12
6 changed files with 578 additions and 19 deletions

View File

@@ -3,6 +3,7 @@
* HarborForge Calendar Scheduler
*
* PLG-CAL-002: Plugin-side handling for pending slot execution.
* PLG-CAL-004: ScheduledGatewayRestart event handling with state persistence.
*
* Responsibilities:
* - Run calendar heartbeat every minute
@@ -10,25 +11,36 @@
* - Wake agent with task context
* - Handle slot status transitions (attended, ongoing, deferred)
* - Manage agent status transitions (idle → busy/on_call)
* - Persist state on ScheduledGatewayRestart and restore on startup
* - Send final heartbeat before graceful shutdown
*
* 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 fs_1 = require("fs");
const path_1 = require("path");
const types_1 = require("./types");
/** State file name */
const STATE_FILENAME = 'calendar-scheduler-state.json';
/** State file version for migration compatibility */
const STATE_VERSION = 1;
/**
* CalendarScheduler manages the periodic heartbeat and slot execution lifecycle.
*/
class CalendarScheduler {
config;
state;
stateFilePath;
constructor(config) {
this.config = {
heartbeatIntervalMs: 60000, // 1 minute default
debug: false,
stateDir: this.getDefaultStateDir(),
...config,
};
this.stateFilePath = (0, path_1.join)(this.config.stateDir, STATE_FILENAME);
this.state = {
isRunning: false,
currentSlot: null,
@@ -36,7 +48,162 @@ class CalendarScheduler {
intervalHandle: null,
deferredSlotIds: new Set(),
isProcessing: false,
isRestartPending: false,
};
// Attempt to restore state from previous persistence
this.restoreState();
}
/**
* Get default state directory (plugin data directory or temp fallback).
*/
getDefaultStateDir() {
// Try to use the plugin's directory or a standard data location
const candidates = [
process.env.OPENCLAW_PLUGIN_DATA_DIR,
process.env.HARBORFORGE_PLUGIN_DIR,
(0, path_1.join)(process.cwd(), '.harborforge'),
(0, path_1.join)(process.cwd(), 'data'),
'/tmp/harborforge',
];
for (const dir of candidates) {
if (dir) {
try {
if (!(0, fs_1.existsSync)(dir)) {
(0, fs_1.mkdirSync)(dir, { recursive: true });
}
// Test write access
const testFile = (0, path_1.join)(dir, '.write-test');
(0, fs_1.writeFileSync)(testFile, '', { flag: 'w' });
return dir;
}
catch {
continue;
}
}
}
// Fallback to current working directory
return process.cwd();
}
/**
* Persist current state to disk for recovery after restart.
*/
persistState(reason) {
try {
const persistedState = {
version: STATE_VERSION,
persistedAt: new Date().toISOString(),
reason,
currentSlot: this.state.currentSlot,
deferredSlotIds: Array.from(this.state.deferredSlotIds),
isProcessing: this.state.isProcessing,
agentStatus: null, // Will be determined at restore time
};
(0, fs_1.writeFileSync)(this.stateFilePath, JSON.stringify(persistedState, null, 2));
this.config.logger.info(`[PLG-CAL-004] State persisted to ${this.stateFilePath} (reason: ${reason})`);
}
catch (err) {
this.config.logger.error('[PLG-CAL-004] Failed to persist state:', err);
}
}
/**
* Restore state from disk if available.
*/
restoreState() {
try {
if (!(0, fs_1.existsSync)(this.stateFilePath)) {
return;
}
const data = (0, fs_1.readFileSync)(this.stateFilePath, 'utf-8');
const persisted = JSON.parse(data);
// Validate version
if (persisted.version !== STATE_VERSION) {
this.config.logger.warn(`[PLG-CAL-004] State version mismatch: ${persisted.version} vs ${STATE_VERSION}`);
this.clearPersistedState();
return;
}
// Restore deferred slot IDs
if (persisted.deferredSlotIds && persisted.deferredSlotIds.length > 0) {
this.state.deferredSlotIds = new Set(persisted.deferredSlotIds);
this.config.logger.info(`[PLG-CAL-004] Restored ${persisted.deferredSlotIds.length} deferred slot(s)`);
}
// If there was a slot in progress, mark it for replanning
if (persisted.isProcessing && persisted.currentSlot) {
this.config.logger.warn(`[PLG-CAL-004] Previous session had in-progress slot: ${this.getSlotId(persisted.currentSlot)}`);
// The slot will be picked up by the next heartbeat and can be resumed or deferred
}
this.config.logger.info(`[PLG-CAL-004] State restored from ${persisted.persistedAt} (reason: ${persisted.reason})`);
// Clear the persisted state after successful restore
this.clearPersistedState();
}
catch (err) {
this.config.logger.error('[PLG-CAL-004] Failed to restore state:', err);
}
}
/**
* Clear persisted state file after successful restore.
*/
clearPersistedState() {
try {
if ((0, fs_1.existsSync)(this.stateFilePath)) {
// In a real implementation, we might want to archive instead of delete
// For now, we'll just clear the content to mark as processed
(0, fs_1.writeFileSync)(this.stateFilePath, JSON.stringify({ restored: true, at: new Date().toISOString() }));
}
}
catch (err) {
this.config.logger.error('[PLG-CAL-004] Failed to clear persisted state:', err);
}
}
/**
* Send a final heartbeat to the backend before shutdown.
*/
async sendFinalHeartbeat(reason) {
try {
this.config.logger.info(`[PLG-CAL-004] Sending final heartbeat (reason: ${reason})`);
// Send agent status update indicating we're going offline
await this.config.bridge.reportAgentStatus({ status: 'offline' });
this.config.logger.info('[PLG-CAL-004] Final heartbeat sent successfully');
}
catch (err) {
this.config.logger.error('[PLG-CAL-004] Failed to send final heartbeat:', err);
}
}
/**
* Handle ScheduledGatewayRestart event.
* PLG-CAL-004: Persist state, send final heartbeat, pause scheduled tasks.
*/
async handleScheduledGatewayRestart(slot) {
this.config.logger.info('[PLG-CAL-004] Handling ScheduledGatewayRestart event');
// 1. Mark restart as pending to prevent new slot processing
this.state.isRestartPending = true;
// 2. Persist current state
this.persistState('ScheduledGatewayRestart');
// 3. If there's a current slot, pause it gracefully
if (this.state.isProcessing && this.state.currentSlot) {
this.config.logger.info('[PLG-CAL-004] Pausing current slot before restart');
await this.pauseCurrentSlot();
}
// 4. Send final heartbeat
await this.sendFinalHeartbeat('ScheduledGatewayRestart');
// 5. Stop the scheduler (pause scheduled tasks)
this.config.logger.info('[PLG-CAL-004] Stopping scheduler due to gateway restart');
this.stop();
// 6. Mark the slot as finished (since we've handled the restart)
const update = {
status: types_1.SlotStatus.FINISHED,
actual_duration: 0, // Restart preparation doesn't take time
};
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('[PLG-CAL-004] Failed to mark restart slot as finished:', err);
}
}
/**
* Start the calendar scheduler.
@@ -48,6 +215,7 @@ class CalendarScheduler {
return;
}
this.state.isRunning = true;
this.state.isRestartPending = false;
this.config.logger.info('Calendar scheduler started');
// Run initial heartbeat immediately
this.runHeartbeat();
@@ -74,6 +242,11 @@ class CalendarScheduler {
if (!this.state.isRunning) {
return;
}
// Skip heartbeat if restart is pending
if (this.state.isRestartPending) {
this.logDebug('Heartbeat skipped: gateway restart pending');
return;
}
this.state.lastHeartbeatAt = new Date();
try {
// Fetch pending slots from backend
@@ -130,7 +303,7 @@ class CalendarScheduler {
return;
}
// Filter out already deferred slots in this session
const eligibleSlots = slots.filter(s => !this.state.deferredSlotIds.has(this.getSlotId(s)));
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;
@@ -144,9 +317,24 @@ class CalendarScheduler {
await this.deferSlot(slot);
this.state.deferredSlotIds.add(this.getSlotId(slot));
}
// Check if this is a ScheduledGatewayRestart event
if (this.isScheduledGatewayRestart(selectedSlot)) {
await this.handleScheduledGatewayRestart(selectedSlot);
return;
}
// Wake agent to execute selected slot
await this.executeSlot(selectedSlot);
}
/**
* Check if a slot is a ScheduledGatewayRestart system event.
*/
isScheduledGatewayRestart(slot) {
if (slot.event_type !== 'system_event' || !slot.event_data) {
return false;
}
const sysData = slot.event_data;
return sysData.event === 'ScheduledGatewayRestart';
}
/**
* Execute a slot by waking the agent.
*/
@@ -544,6 +732,18 @@ Please use this time for the scheduled activity.`;
getCurrentSlot() {
return this.state.currentSlot;
}
/**
* Check if a gateway restart is pending.
*/
isRestartPending() {
return this.state.isRestartPending;
}
/**
* Get the path to the state file.
*/
getStateFilePath() {
return this.stateFilePath;
}
}
exports.CalendarScheduler = CalendarScheduler;
/**