/** * session_health — Check and fix session state consistency. * * Detects zombie sessions (active=true but session dead) and stale workers. * Checks the sessions map for each worker's current model. */ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; import { readProjects, updateWorker, getSessionForModel } from "../projects.js"; import { type StateLabel } from "../task-managers/task-manager.js"; import { createProvider } from "../task-managers/index.js"; import { log as auditLog } from "../audit.js"; export function createSessionHealthTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ name: "session_health", label: "Session Health", description: `Check session state consistency across all projects. Detects: active workers with no session in their sessions map, stale workers (>2 hours), and state mismatches. With autoFix=true, clears zombie states and reverts GitLab labels. Pass activeSessions (from sessions_list) so the tool can verify liveness.`, parameters: { type: "object", properties: { autoFix: { type: "boolean", description: "Automatically fix zombie sessions and stale active flags. Default: false.", }, activeSessions: { type: "array", items: { type: "string" }, description: "List of currently alive session IDs from sessions_list. Used to detect zombies.", }, }, }, async execute(_id: string, params: Record) { const autoFix = (params.autoFix as boolean) ?? false; const activeSessions = (params.activeSessions as string[]) ?? []; const workspaceDir = ctx.workspaceDir; if (!workspaceDir) { throw new Error("No workspace directory available in tool context"); } const data = await readProjects(workspaceDir); const issues: Array> = []; let fixesApplied = 0; for (const [groupId, project] of Object.entries(data.projects)) { const { provider } = createProvider({ repo: project.repo, }); for (const role of ["dev", "qa"] as const) { const worker = project[role]; const currentSessionKey = worker.model ? getSessionForModel(worker, worker.model) : null; // Check 1: Active but no session key for current model if (worker.active && !currentSessionKey) { const issue: Record = { type: "active_no_session", severity: "critical", project: project.name, groupId, role, model: worker.model, message: `${role.toUpperCase()} marked active but has no session for model "${worker.model}"`, }; if (autoFix) { await updateWorker(workspaceDir, groupId, role, { active: false, issueId: null, }); issue.fixed = true; fixesApplied++; } issues.push(issue); } // Check 2: Active with session but session is dead (zombie) if ( worker.active && currentSessionKey && activeSessions.length > 0 && !activeSessions.includes(currentSessionKey) ) { const issue: Record = { type: "zombie_session", severity: "critical", project: project.name, groupId, role, sessionKey: currentSessionKey, model: worker.model, message: `${role.toUpperCase()} session ${currentSessionKey} not found in active sessions`, }; if (autoFix) { // Revert issue label const revertLabel: StateLabel = role === "dev" ? "To Do" : "To Test"; const currentLabel: StateLabel = role === "dev" ? "Doing" : "Testing"; try { if (worker.issueId) { const primaryIssueId = Number(worker.issueId.split(",")[0]); await provider.transitionLabel(primaryIssueId, currentLabel, revertLabel); issue.labelReverted = `${currentLabel} → ${revertLabel}`; } } catch { issue.labelRevertFailed = true; } // Clear the dead session from the sessions map const updatedSessions = { ...worker.sessions }; if (worker.model) { updatedSessions[worker.model] = null; } await updateWorker(workspaceDir, groupId, role, { active: false, issueId: null, sessions: updatedSessions, }); issue.fixed = true; fixesApplied++; } issues.push(issue); } // Check 3: Active for >2 hours (stale) if (worker.active && worker.startTime) { const startMs = new Date(worker.startTime).getTime(); const nowMs = Date.now(); const hoursActive = (nowMs - startMs) / (1000 * 60 * 60); if (hoursActive > 2) { issues.push({ type: "stale_worker", severity: "warning", project: project.name, groupId, role, hoursActive: Math.round(hoursActive * 10) / 10, sessionKey: currentSessionKey, issueId: worker.issueId, message: `${role.toUpperCase()} has been active for ${Math.round(hoursActive * 10) / 10}h — may need attention`, }); } } // Check 4: Inactive but still has issueId (should have been cleared) if (!worker.active && worker.issueId) { const issue: Record = { type: "inactive_with_issue", severity: "warning", project: project.name, groupId, role, issueId: worker.issueId, message: `${role.toUpperCase()} inactive but still has issueId "${worker.issueId}"`, }; if (autoFix) { await updateWorker(workspaceDir, groupId, role, { issueId: null, }); issue.fixed = true; fixesApplied++; } issues.push(issue); } } } // Audit log await auditLog(workspaceDir, "health_check", { projectsScanned: Object.keys(data.projects).length, issuesFound: issues.length, fixesApplied, autoFix, activeSessionsProvided: activeSessions.length > 0, }); const result = { healthy: issues.length === 0, issuesFound: issues.length, fixesApplied, issues, note: activeSessions.length === 0 ? "No activeSessions provided — zombie detection skipped. Call sessions_list and pass the result for full health check." : undefined, }; return jsonResult(result); }, }); }