From 1156c8550f11db8ac9c038d16c155853853b4f4f Mon Sep 17 00:00:00 2001 From: "Doink (OpenClaw)" Date: Thu, 12 Mar 2026 22:49:13 -0700 Subject: [PATCH] fix: prevent reconcileAndEnrich from falsely stopping fuse-tracked sessions When the opencode adapter's list() doesn't return a running session (because opencode doesn't persist running sessions to native storage), reconcileAndEnrich() would mark it stopped after the 30s grace period. This fires before the lifecycle fuse ever gets a chance to detect the real session completion. Fix: check for an active fuse AND alive PID before marking a session stopped. If both conditions hold, the session stays running and the fuse handles lifecycle detection. Also wires up fuse set/cancel in the daemon's launch/stop paths. Fixes #146 --- src/daemon/server.ts | 23 ++++- src/daemon/session-tracker.test.ts | 138 +++++++++++++++++++++++++++++ src/daemon/session-tracker.ts | 19 ++++ 3 files changed, 179 insertions(+), 1 deletion(-) diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 9aa2b4b..cdcd47b 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -116,7 +116,10 @@ export async function startDaemon(opts: DaemonStartOpts = {}): Promise<{ defaultDurationMs: 10 * 60 * 1000, emitter, }); - const sessionTracker = new SessionTracker(state, { adapters }); + const sessionTracker = new SessionTracker(state, { + adapters, + fuseEngine, + }); const metrics = new MetricsRegistry(lockManager, fuseEngine); // Wire up events @@ -684,6 +687,19 @@ function createRequestHandler(ctx: HandlerContext) { ctx.lockManager.autoLock(cwd, session.pid, session.id); } + // Set a lifecycle fuse so reconcileAndEnrich won't falsely mark this + // session as stopped when the adapter's list() doesn't return it (#146). + // The fuse acts as a marker; reconcileAndEnrich checks fuse + PID liveness. + if (cwd && session.pid) { + const LIFECYCLE_FUSE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours + ctx.fuseEngine.setFuse({ + directory: cwd, + sessionId: session.id, + ttlMs: LIFECYCLE_FUSE_TTL_MS, + label: `lifecycle:${adapterName}:${session.id.slice(0, 8)}`, + }); + } + return record; } @@ -712,6 +728,11 @@ function createRequestHandler(ctx: HandlerContext) { ctx.lockManager.autoUnlock(sessionId); } + // Cancel lifecycle fuse (#146) + if (launchRecord?.cwd) { + ctx.fuseEngine.cancelFuse(launchRecord.cwd); + } + // Mark stopped in launch metadata const stopped = ctx.sessionTracker.onSessionExit(sessionId); if (stopped) { diff --git a/src/daemon/session-tracker.test.ts b/src/daemon/session-tracker.test.ts index 95b6239..3442e09 100644 --- a/src/daemon/session-tracker.test.ts +++ b/src/daemon/session-tracker.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentSession, DiscoveredSession } from "../core/types.js"; +import { FuseEngine } from "./fuse-engine.js"; import { SessionTracker } from "./session-tracker.js"; import { StateManager } from "./state.js"; @@ -297,6 +298,143 @@ describe("SessionTracker", () => { // Should not try to stop it again expect(stoppedLaunchIds).not.toContain("already-stopped"); }); + + it("keeps session running when fuse is active and PID is alive (#146)", () => { + // Create a tracker with fuse engine and live PID + const fuseEngine = new FuseEngine(state, { defaultDurationMs: 60_000 }); + const fuseTracker = new SessionTracker(state, { + adapters: {}, + isProcessAlive: () => true, + fuseEngine, + }); + + // Session launched 2+ minutes ago (past grace period) + fuseTracker.track( + makeSession({ + id: "fused-session", + status: "running", + startedAt: new Date(Date.now() - 120_000), + pid: 12345, + cwd: "/tmp/fuse-test", + }), + "opencode", + ); + + // Set a lifecycle fuse for this session + fuseEngine.setFuse({ + directory: "/tmp/fuse-test", + sessionId: "fused-session", + ttlMs: 4 * 60 * 60 * 1000, + label: "lifecycle:opencode:fused-se", + }); + + // Adapter returns empty (opencode doesn't persist running sessions) + const { sessions, stoppedLaunchIds } = fuseTracker.reconcileAndEnrich( + [], + new Set(["opencode"]), + ); + + // Session must NOT be marked stopped — fuse + PID alive protects it + expect(stoppedLaunchIds).not.toContain("fused-session"); + expect(sessions.map((s) => s.id)).toContain("fused-session"); + expect(state.getSession("fused-session")?.status).toBe("running"); + + fuseEngine.shutdown(); + }); + + it("marks session stopped when fuse is active but PID is dead (#146)", () => { + const fuseEngine = new FuseEngine(state, { defaultDurationMs: 60_000 }); + const deadPidTracker = new SessionTracker(state, { + adapters: {}, + isProcessAlive: () => false, // PID is dead + fuseEngine, + }); + + deadPidTracker.track( + makeSession({ + id: "dead-pid-session", + status: "running", + startedAt: new Date(Date.now() - 120_000), + pid: 99999, + cwd: "/tmp/dead-test", + }), + "opencode", + ); + + // Fuse exists but PID is dead — should still be marked stopped + fuseEngine.setFuse({ + directory: "/tmp/dead-test", + sessionId: "dead-pid-session", + ttlMs: 4 * 60 * 60 * 1000, + }); + + const { stoppedLaunchIds } = deadPidTracker.reconcileAndEnrich( + [], + new Set(["opencode"]), + ); + + expect(stoppedLaunchIds).toContain("dead-pid-session"); + expect(state.getSession("dead-pid-session")?.status).toBe("stopped"); + + fuseEngine.shutdown(); + }); + + it("marks session stopped without fuse even if PID is alive (#146)", () => { + // No fuse engine — falls back to original behavior + const noFuseTracker = new SessionTracker(state, { + adapters: {}, + isProcessAlive: () => true, + }); + + noFuseTracker.track( + makeSession({ + id: "no-fuse-session", + status: "running", + startedAt: new Date(Date.now() - 120_000), + pid: 12345, + }), + "claude-code", + ); + + const { stoppedLaunchIds } = noFuseTracker.reconcileAndEnrich( + [], + new Set(["claude-code"]), + ); + + // Without a fuse engine, session should be marked stopped (existing behavior) + expect(stoppedLaunchIds).toContain("no-fuse-session"); + expect(state.getSession("no-fuse-session")?.status).toBe("stopped"); + }); + + it("follows grace period logic when no fuse exists (#146)", () => { + const fuseEngine = new FuseEngine(state, { defaultDurationMs: 60_000 }); + const fuseTracker = new SessionTracker(state, { + adapters: {}, + isProcessAlive: () => true, + fuseEngine, + }); + + // Session past grace period, PID alive, but NO fuse set + fuseTracker.track( + makeSession({ + id: "no-fuse-old", + status: "running", + startedAt: new Date(Date.now() - 120_000), + pid: 12345, + }), + "opencode", + ); + + const { stoppedLaunchIds } = fuseTracker.reconcileAndEnrich( + [], + new Set(["opencode"]), + ); + + // No fuse for this session — should be marked stopped + expect(stoppedLaunchIds).toContain("no-fuse-old"); + + fuseEngine.shutdown(); + }); }); describe("cleanupDeadLaunches", () => { diff --git a/src/daemon/session-tracker.ts b/src/daemon/session-tracker.ts index 2ffbc39..650cb2d 100644 --- a/src/daemon/session-tracker.ts +++ b/src/daemon/session-tracker.ts @@ -3,12 +3,15 @@ import type { AgentSession, DiscoveredSession, } from "../core/types.js"; +import type { FuseEngine } from "./fuse-engine.js"; import type { SessionRecord, StateManager } from "./state.js"; export interface SessionTrackerOpts { adapters: Record; /** Override PID liveness check for testing (default: process.kill(pid, 0)) */ isProcessAlive?: (pid: number) => boolean; + /** Optional fuse engine reference — sessions with active fuses won't be marked stopped */ + fuseEngine?: FuseEngine; } /** @@ -33,12 +36,14 @@ export class SessionTracker { private state: StateManager; private adapters: Record; private readonly isProcessAlive: (pid: number) => boolean; + private fuseEngine?: FuseEngine; private cleanupHandle: ReturnType | null = null; constructor(state: StateManager, opts: SessionTrackerOpts) { this.state = state; this.adapters = opts.adapters; this.isProcessAlive = opts.isProcessAlive ?? defaultIsProcessAlive; + this.fuseEngine = opts.fuseEngine; } /** @@ -150,6 +155,20 @@ export class SessionTracker { continue; } + // Fuse guard: if the fuse engine has an active fuse for this session + // AND the PID is alive, the session is still running — the adapter + // just can't see it (e.g. opencode doesn't persist running sessions). + if (record.pid && this.isProcessAlive(record.pid)) { + const hasActiveFuse = + this.fuseEngine?.listActive().some((f) => f.sessionId === id) ?? + false; + if (hasActiveFuse) { + // Fuse is tracking this session and PID is alive — keep running + sessions.push(record); + continue; + } + } + // Session disappeared from adapter results — mark stopped this.state.setSession(id, { ...record,