Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion src/daemon/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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) {
Expand Down
138 changes: 138 additions & 0 deletions src/daemon/session-tracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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", () => {
Expand Down
19 changes: 19 additions & 0 deletions src/daemon/session-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, AgentAdapter>;
/** 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;
}

/**
Expand All @@ -33,12 +36,14 @@ export class SessionTracker {
private state: StateManager;
private adapters: Record<string, AgentAdapter>;
private readonly isProcessAlive: (pid: number) => boolean;
private fuseEngine?: FuseEngine;
private cleanupHandle: ReturnType<typeof setInterval> | null = null;

constructor(state: StateManager, opts: SessionTrackerOpts) {
this.state = state;
this.adapters = opts.adapters;
this.isProcessAlive = opts.isProcessAlive ?? defaultIsProcessAlive;
this.fuseEngine = opts.fuseEngine;
}

/**
Expand Down Expand Up @@ -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,
Expand Down
Loading