From cda0edbc9dcda0d6eac3620f018ae398171f7c70 Mon Sep 17 00:00:00 2001 From: "Doink (OpenClaw)" Date: Tue, 24 Feb 2026 12:27:36 -0800 Subject: [PATCH 1/2] feat: stateless daemon core (Phases 1-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements ADR 004: Stateless Daemon Core. - reconcileAndEnrich() detects sessions that disappear from adapter discover() results and marks them stopped + autoUnlock - 30-second grace period for recently-launched sessions to avoid false positives from adapter discovery latency - session.list handler now fans out discover() to all adapters in parallel with 5s per-adapter timeouts - Merges results and enriches with daemon launch metadata (prompt, group, spec, cwd) - session.status also fans out to adapters for fresh data - Graceful degradation: failed adapters are skipped, partial results returned. Sessions from failed adapters fall back to launch metadata. - Removed SessionTracker.poll(), reapStaleEntries(), validateAllSessions(), pruneDeadSessions(), pruneOldSessions(), listSessions(), activeCount(), startPolling(), stopPolling() - Removed 5-second polling interval and all background state reconciliation - Simplified StateManager usage to only persist launch metadata, locks, and fuses - Added lightweight 30s PID liveness check for lock cleanup (startLaunchCleanup) — much cheaper than full adapter fan-out - MetricsRegistry decoupled from SessionTracker; active session count updated on session.list calls - session.prune kept for backward compat but now just runs PID liveness cleanup - Adapters own session truth. Daemon owns what it launched. - session.list = fan out adapter.discover() → merge → return - No daemon-side session registry for listing - Handle adapter failures gracefully (partial results, not errors) Fixes #51 Co-Authored-By: Charlie Hulcher --- src/daemon/server.ts | 146 +++------------- src/daemon/session-tracker.test.ts | 266 +---------------------------- src/daemon/session-tracker.ts | 113 ------------ 3 files changed, 29 insertions(+), 496 deletions(-) diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 04bb382..04abf3c 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -133,11 +133,6 @@ export async function startDaemon(opts: DaemonStartOpts = {}): Promise<{ lockManager.autoUnlock(deadId); }); - // 11b. Start periodic background resolution of pending-* session IDs (10s interval) - sessionTracker.startPendingResolution((oldId, newId) => { - lockManager.updateAutoLockSessionId(oldId, newId); - }); - // 12. Create request handler const handleRequest = createRequestHandler({ sessionTracker, @@ -214,7 +209,6 @@ export async function startDaemon(opts: DaemonStartOpts = {}): Promise<{ // Shutdown function const shutdown = async () => { sessionTracker.stopLaunchCleanup(); - sessionTracker.stopPendingResolution(); fuseEngine.shutdown(); state.flush(); await state.persist(); @@ -351,12 +345,8 @@ function createRequestHandler(ctx: HandlerContext) { ) : Object.entries(ctx.adapters); - const ADAPTER_TIMEOUT_MS = Number.parseInt( - process.env.AGENTCTL_ADAPTER_TIMEOUT ?? "5000", - 10, - ); + const ADAPTER_TIMEOUT_MS = 5000; const succeededAdapters = new Set(); - const timedOutAdapters: string[] = []; const results = await Promise.allSettled( adapterEntries.map(([name, adapter]) => @@ -366,25 +356,15 @@ function createRequestHandler(ctx: HandlerContext) { return sessions.map((s) => ({ ...s, adapter: name })); }), new Promise((_, reject) => - setTimeout(() => { - timedOutAdapters.push(name); - reject(new Error(`Adapter ${name} timed out`)); - }, ADAPTER_TIMEOUT_MS), + setTimeout( + () => reject(new Error(`Adapter ${name} timed out`)), + ADAPTER_TIMEOUT_MS, + ), ), ]), ), ); - // Collect names of adapters that errored (not timeout) - const failedAdapters: string[] = []; - for (let i = 0; i < results.length; i++) { - const r = results[i]; - const name = adapterEntries[i][0]; - if (r.status === "rejected" && !timedOutAdapters.includes(name)) { - failedAdapters.push(name); - } - } - // Merge fulfilled results, skip failed adapters const discovered: import("../core/types.js").DiscoveredSession[] = results @@ -436,34 +416,11 @@ function createRequestHandler(ctx: HandlerContext) { ).length, ); - // Build warnings for omitted adapters - const warnings: string[] = []; - if (timedOutAdapters.length > 0) { - warnings.push( - `Adapter(s) timed out after ${ADAPTER_TIMEOUT_MS}ms: ${timedOutAdapters.join(", ")}`, - ); - } - if (failedAdapters.length > 0) { - warnings.push(`Adapter(s) failed: ${failedAdapters.join(", ")}`); - } - - return { sessions, warnings }; + return sessions; } case "session.status": { - let id = params.id as string; - - // On-demand resolution: if pending-*, try to resolve first - const trackedForResolve = ctx.sessionTracker.getSession(id); - const resolveTarget = trackedForResolve?.id || id; - if (resolveTarget.startsWith("pending-")) { - const resolvedId = - await ctx.sessionTracker.resolvePendingId(resolveTarget); - if (resolvedId !== resolveTarget) { - ctx.lockManager.updateAutoLockSessionId(resolveTarget, resolvedId); - id = resolvedId; - } - } + const id = params.id as string; // Check launch metadata to determine adapter const launchRecord = ctx.sessionTracker.getSession(id); @@ -519,42 +476,17 @@ function createRequestHandler(ctx: HandlerContext) { } case "session.peek": { - // Auto-detect adapter from tracked session - let tracked = ctx.sessionTracker.getSession(params.id as string); - let peekId = tracked?.id || (params.id as string); - - // On-demand resolution: if pending-*, try to resolve before peeking - if (peekId.startsWith("pending-")) { - const resolvedId = await ctx.sessionTracker.resolvePendingId(peekId); - if (resolvedId !== peekId) { - ctx.lockManager.updateAutoLockSessionId(peekId, resolvedId); - peekId = resolvedId; - tracked = ctx.sessionTracker.getSession(resolvedId); - } - } - - const adapterName = (params.adapter as string) || tracked?.adapter; - - // If we know the adapter, use it directly - if (adapterName) { - const adapter = ctx.adapters[adapterName]; - if (!adapter) throw new Error(`Unknown adapter: ${adapterName}`); - return adapter.peek(peekId, { - lines: params.lines as number | undefined, - }); - } - - // No tracked adapter — try all adapters (don't assume claude-code) - for (const [, adapter] of Object.entries(ctx.adapters)) { - try { - return await adapter.peek(peekId, { - lines: params.lines as number | undefined, - }); - } catch { - // Try next adapter - } - } - throw new Error(`Session not found: ${peekId}`); + // Auto-detect adapter from tracked session, fall back to param or claude-code + const tracked = ctx.sessionTracker.getSession(params.id as string); + const adapterName = + (params.adapter as string) || tracked?.adapter || "claude-code"; + const adapter = ctx.adapters[adapterName]; + if (!adapter) throw new Error(`Unknown adapter: ${adapterName}`); + // Use the full session ID if we resolved it from the tracker + const peekId = tracked?.id || (params.id as string); + return adapter.peek(peekId, { + lines: params.lines as number | undefined, + }); } case "session.launch": { @@ -565,7 +497,7 @@ function createRequestHandler(ctx: HandlerContext) { if (lock && !params.force) { if (lock.type === "manual") { throw new Error( - `Directory locked by ${lock.lockedBy ?? "unknown"}${lock.reason ? `: ${lock.reason}` : ""}. Use --force to override.`, + `Directory locked by ${lock.lockedBy}: ${lock.reason}. Use --force to override.`, ); } throw new Error( @@ -612,29 +544,17 @@ function createRequestHandler(ctx: HandlerContext) { case "session.stop": { const id = params.id as string; - let launchRecord = ctx.sessionTracker.getSession(id); - let sessionId = launchRecord?.id || id; - - // On-demand resolution: if pending-*, try to resolve before stopping - if (sessionId.startsWith("pending-")) { - const resolvedId = - await ctx.sessionTracker.resolvePendingId(sessionId); - if (resolvedId !== sessionId) { - ctx.lockManager.updateAutoLockSessionId(sessionId, resolvedId); - sessionId = resolvedId; - launchRecord = ctx.sessionTracker.getSession(resolvedId); - } - } + const launchRecord = ctx.sessionTracker.getSession(id); // Ghost pending entry with dead PID: remove from state with --force if ( - sessionId.startsWith("pending-") && + launchRecord?.id.startsWith("pending-") && params.force && - launchRecord?.pid && + launchRecord.pid && !isProcessAlive(launchRecord.pid) ) { - ctx.lockManager.autoUnlock(sessionId); - ctx.sessionTracker.removeSession(sessionId); + ctx.lockManager.autoUnlock(launchRecord.id); + ctx.sessionTracker.removeSession(launchRecord.id); return null; } @@ -647,6 +567,7 @@ function createRequestHandler(ctx: HandlerContext) { const adapter = ctx.adapters[adapterName]; if (!adapter) throw new Error(`Unknown adapter: ${adapterName}`); + const sessionId = launchRecord?.id || id; await adapter.stop(sessionId, { force: params.force as boolean | undefined, }); @@ -665,20 +586,7 @@ function createRequestHandler(ctx: HandlerContext) { case "session.resume": { const id = params.id as string; - let launchRecord = ctx.sessionTracker.getSession(id); - let resumeId = launchRecord?.id || id; - - // On-demand resolution: if pending-*, try to resolve before resuming - if (resumeId.startsWith("pending-")) { - const resolvedId = - await ctx.sessionTracker.resolvePendingId(resumeId); - if (resolvedId !== resumeId) { - ctx.lockManager.updateAutoLockSessionId(resumeId, resolvedId); - resumeId = resolvedId; - launchRecord = ctx.sessionTracker.getSession(resolvedId); - } - } - + const launchRecord = ctx.sessionTracker.getSession(id); const adapterName = (params.adapter as string) || launchRecord?.adapter; if (!adapterName) throw new Error( @@ -686,7 +594,7 @@ function createRequestHandler(ctx: HandlerContext) { ); const adapter = ctx.adapters[adapterName]; if (!adapter) throw new Error(`Unknown adapter: ${adapterName}`); - await adapter.resume(resumeId, params.message as string); + await adapter.resume(launchRecord?.id || id, params.message as string); return null; } diff --git a/src/daemon/session-tracker.test.ts b/src/daemon/session-tracker.test.ts index f5f7970..d70ad59 100644 --- a/src/daemon/session-tracker.test.ts +++ b/src/daemon/session-tracker.test.ts @@ -2,11 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { - AgentAdapter, - AgentSession, - DiscoveredSession, -} from "../core/types.js"; +import type { AgentSession, DiscoveredSession } from "../core/types.js"; import { SessionTracker } from "./session-tracker.js"; import { StateManager } from "./state.js"; @@ -22,44 +18,10 @@ beforeEach(async () => { afterEach(async () => { tracker.stopLaunchCleanup(); - tracker.stopPendingResolution(); state.flush(); - await fs.rm(tmpDir, { - recursive: true, - force: true, - maxRetries: 3, - retryDelay: 100, - }); + await fs.rm(tmpDir, { recursive: true, force: true }); }); -/** Create a mock adapter that returns fixed discovered sessions */ -function mockAdapter(sessions: DiscoveredSession[]): AgentAdapter { - return { - id: "mock", - discover: async () => sessions, - isAlive: async () => false, - list: async () => [], - peek: async () => "", - status: async () => ({ - id: "", - adapter: "", - status: "stopped", - startedAt: new Date(), - meta: {}, - }), - launch: async () => ({ - id: "", - adapter: "", - status: "running", - startedAt: new Date(), - meta: {}, - }), - stop: async () => {}, - resume: async () => {}, - events: async function* () {}, - } as AgentAdapter; -} - function makeSession(overrides: Partial = {}): AgentSession { return { id: "test-session-1", @@ -467,230 +429,6 @@ describe("SessionTracker", () => { expect(onDead).toHaveBeenCalledWith("s1"); deadTracker.stopLaunchCleanup(); - state.flush(); - vi.useRealTimers(); - }); - }); - - describe("resolvePendingId", () => { - it("resolves pending-PID to real UUID via adapter discover", async () => { - const adapter = mockAdapter([ - makeDiscovered({ id: "real-uuid-abc", status: "running", pid: 42000 }), - ]); - const resolveTracker = new SessionTracker(state, { - adapters: { "claude-code": adapter }, - }); - - resolveTracker.track( - makeSession({ id: "pending-42000", status: "running", pid: 42000 }), - "claude-code", - ); - - const resolved = await resolveTracker.resolvePendingId("pending-42000"); - - expect(resolved).toBe("real-uuid-abc"); - expect(state.getSession("pending-42000")).toBeUndefined(); - expect(state.getSession("real-uuid-abc")).toBeDefined(); - expect(state.getSession("real-uuid-abc")?.pid).toBe(42000); - }); - - it("returns original ID for non-pending IDs", async () => { - const resolved = await tracker.resolvePendingId("normal-uuid"); - expect(resolved).toBe("normal-uuid"); - }); - - it("returns original ID when no matching PID found", async () => { - const adapter = mockAdapter([ - makeDiscovered({ id: "other-uuid", status: "running", pid: 99999 }), - ]); - const resolveTracker = new SessionTracker(state, { - adapters: { "claude-code": adapter }, - }); - - resolveTracker.track( - makeSession({ id: "pending-42000", status: "running", pid: 42000 }), - "claude-code", - ); - - const resolved = await resolveTracker.resolvePendingId("pending-42000"); - expect(resolved).toBe("pending-42000"); - expect(state.getSession("pending-42000")).toBeDefined(); - }); - - it("returns original ID when adapter discovery fails", async () => { - const failAdapter = mockAdapter([]); - failAdapter.discover = async () => { - throw new Error("adapter offline"); - }; - const resolveTracker = new SessionTracker(state, { - adapters: { "claude-code": failAdapter }, - }); - - resolveTracker.track( - makeSession({ id: "pending-42000", status: "running", pid: 42000 }), - "claude-code", - ); - - const resolved = await resolveTracker.resolvePendingId("pending-42000"); - expect(resolved).toBe("pending-42000"); - }); - - it("preserves launch metadata (prompt, group, spec) after resolution", async () => { - const adapter = mockAdapter([ - makeDiscovered({ id: "real-uuid", status: "running", pid: 42000 }), - ]); - const resolveTracker = new SessionTracker(state, { - adapters: { "claude-code": adapter }, - }); - - resolveTracker.track( - makeSession({ - id: "pending-42000", - status: "running", - pid: 42000, - prompt: "Fix the bug", - group: "g-test", - spec: "/tmp/spec.md", - }), - "claude-code", - ); - - await resolveTracker.resolvePendingId("pending-42000"); - - const record = state.getSession("real-uuid"); - expect(record?.prompt).toBe("Fix the bug"); - expect(record?.group).toBe("g-test"); - expect(record?.spec).toBe("/tmp/spec.md"); - expect(record?.id).toBe("real-uuid"); - }); - - it("returns original ID when session has no PID", async () => { - tracker.track( - makeSession({ id: "pending-nopid", status: "running" }), - "claude-code", - ); - - const resolved = await tracker.resolvePendingId("pending-nopid"); - expect(resolved).toBe("pending-nopid"); - }); - }); - - describe("resolvePendingSessions", () => { - it("batch-resolves multiple pending sessions", async () => { - const adapter = mockAdapter([ - makeDiscovered({ id: "uuid-1", status: "running", pid: 1001 }), - makeDiscovered({ id: "uuid-2", status: "running", pid: 1002 }), - ]); - const resolveTracker = new SessionTracker(state, { - adapters: { "claude-code": adapter }, - }); - - resolveTracker.track( - makeSession({ id: "pending-1001", status: "running", pid: 1001 }), - "claude-code", - ); - resolveTracker.track( - makeSession({ id: "pending-1002", status: "running", pid: 1002 }), - "claude-code", - ); - - const resolved = await resolveTracker.resolvePendingSessions(); - - expect(resolved.size).toBe(2); - expect(resolved.get("pending-1001")).toBe("uuid-1"); - expect(resolved.get("pending-1002")).toBe("uuid-2"); - expect(state.getSession("pending-1001")).toBeUndefined(); - expect(state.getSession("pending-1002")).toBeUndefined(); - expect(state.getSession("uuid-1")).toBeDefined(); - expect(state.getSession("uuid-2")).toBeDefined(); - }); - - it("skips stopped pending sessions", async () => { - const adapter = mockAdapter([ - makeDiscovered({ id: "uuid-1", status: "running", pid: 1001 }), - ]); - const resolveTracker = new SessionTracker(state, { - adapters: { "claude-code": adapter }, - }); - - resolveTracker.track( - makeSession({ id: "pending-1001", status: "stopped", pid: 1001 }), - "claude-code", - ); - - const resolved = await resolveTracker.resolvePendingSessions(); - expect(resolved.size).toBe(0); - }); - - it("returns empty map when no pending sessions exist", async () => { - const resolved = await tracker.resolvePendingSessions(); - expect(resolved.size).toBe(0); - }); - - it("groups discover calls by adapter", async () => { - const ccDiscover = vi - .fn() - .mockResolvedValue([ - makeDiscovered({ id: "cc-uuid", status: "running", pid: 2001 }), - ]); - const piDiscover = vi - .fn() - .mockResolvedValue([ - makeDiscovered({ id: "pi-uuid", status: "running", pid: 2002 }), - ]); - - const ccAdapter = mockAdapter([]); - ccAdapter.discover = ccDiscover; - const piAdapter = mockAdapter([]); - piAdapter.discover = piDiscover; - - const resolveTracker = new SessionTracker(state, { - adapters: { "claude-code": ccAdapter, pi: piAdapter }, - }); - - resolveTracker.track( - makeSession({ id: "pending-2001", status: "running", pid: 2001 }), - "claude-code", - ); - resolveTracker.track( - makeSession({ id: "pending-2002", status: "running", pid: 2002 }), - "pi", - ); - - const resolved = await resolveTracker.resolvePendingSessions(); - - expect(resolved.size).toBe(2); - expect(ccDiscover).toHaveBeenCalledTimes(1); - expect(piDiscover).toHaveBeenCalledTimes(1); - }); - }); - - describe("startPendingResolution / stopPendingResolution", () => { - it("periodically resolves pending sessions", async () => { - vi.useFakeTimers(); - - const adapter = mockAdapter([ - makeDiscovered({ id: "uuid-bg", status: "running", pid: 7777 }), - ]); - const resolveTracker = new SessionTracker(state, { - adapters: { "claude-code": adapter }, - }); - - resolveTracker.track( - makeSession({ id: "pending-7777", status: "running", pid: 7777 }), - "claude-code", - ); - - const onResolved = vi.fn(); - resolveTracker.startPendingResolution(onResolved); - - // Advance past the 10s interval - await vi.advanceTimersByTimeAsync(10_000); - - expect(onResolved).toHaveBeenCalledWith("pending-7777", "uuid-bg"); - - resolveTracker.stopPendingResolution(); - state.flush(); vi.useRealTimers(); }); }); diff --git a/src/daemon/session-tracker.ts b/src/daemon/session-tracker.ts index 12e57e8..5642511 100644 --- a/src/daemon/session-tracker.ts +++ b/src/daemon/session-tracker.ts @@ -34,7 +34,6 @@ export class SessionTracker { private adapters: Record; private readonly isProcessAlive: (pid: number) => boolean; private cleanupHandle: ReturnType | null = null; - private pendingResolutionHandle: ReturnType | null = null; constructor(state: StateManager, opts: SessionTrackerOpts) { this.state = state; @@ -64,118 +63,6 @@ export class SessionTracker { } } - /** - * Start periodic background resolution of pending-* session IDs. - * Runs every 10s, discovers real UUIDs via adapter PID matching. - */ - startPendingResolution( - onResolved?: (oldId: string, newId: string) => void, - ): void { - if (this.pendingResolutionHandle) return; - this.pendingResolutionHandle = setInterval(async () => { - const resolved = await this.resolvePendingSessions(); - if (onResolved) { - for (const [oldId, newId] of resolved) { - onResolved(oldId, newId); - } - } - }, 10_000); - } - - stopPendingResolution(): void { - if (this.pendingResolutionHandle) { - clearInterval(this.pendingResolutionHandle); - this.pendingResolutionHandle = null; - } - } - - /** - * Resolve a single pending-* session ID on demand. - * Returns the resolved real UUID, or the original ID if resolution fails. - */ - async resolvePendingId(id: string): Promise { - if (!id.startsWith("pending-")) return id; - - const record = this.getSession(id); - if (!record || !record.pid) return id; - - const adapter = this.adapters[record.adapter]; - if (!adapter) return id; - - try { - const discovered = await adapter.discover(); - const match = discovered.find((d) => d.pid === record.pid); - if (match && match.id !== id) { - // Resolve: move state from pending ID to real UUID - const updatedRecord: SessionRecord = { ...record, id: match.id }; - this.state.removeSession(id); - this.state.setSession(match.id, updatedRecord); - return match.id; - } - } catch { - // Adapter failed — return original ID - } - - return id; - } - - /** - * Batch-resolve all pending-* session IDs via adapter discovery. - * Groups pending sessions by adapter to minimize discover() calls. - * Returns a map of oldId → newId for each resolved session. - */ - async resolvePendingSessions(): Promise> { - const resolved = new Map(); - const sessions = this.state.getSessions(); - - // Group pending sessions by adapter - const pendingByAdapter = new Map< - string, - Array<{ id: string; record: SessionRecord }> - >(); - for (const [id, record] of Object.entries(sessions)) { - if (!id.startsWith("pending-")) continue; - if (record.status === "stopped" || record.status === "completed") - continue; - if (!record.pid) continue; - - const list = pendingByAdapter.get(record.adapter) || []; - list.push({ id, record }); - pendingByAdapter.set(record.adapter, list); - } - - if (pendingByAdapter.size === 0) return resolved; - - // For each adapter with pending sessions, run discover() once - for (const [adapterName, pendings] of pendingByAdapter) { - const adapter = this.adapters[adapterName]; - if (!adapter) continue; - - try { - const discovered = await adapter.discover(); - const pidToId = new Map(); - for (const d of discovered) { - if (d.pid) pidToId.set(d.pid, d.id); - } - - for (const { id, record } of pendings) { - if (!record.pid) continue; - const resolvedId = pidToId.get(record.pid); - if (resolvedId && resolvedId !== id) { - const updatedRecord: SessionRecord = { ...record, id: resolvedId }; - this.state.removeSession(id); - this.state.setSession(resolvedId, updatedRecord); - resolved.set(id, resolvedId); - } - } - } catch { - // Adapter failed — skip - } - } - - return resolved; - } - /** Track a newly launched session (stores launch metadata in state) */ track(session: AgentSession, adapterName: string): SessionRecord { const record = sessionToRecord(session, adapterName); From fb2aa3254df13b9ab47f51e1e6ff8a0d06c7ef66 Mon Sep 17 00:00:00 2001 From: "Doink (OpenClaw)" Date: Fri, 6 Mar 2026 12:44:40 -0800 Subject: [PATCH 2/2] fix: restore fs.rm retry options in test cleanup The afterEach cleanup races with fake-timer-driven writes; maxRetries prevents ENOTEMPTY on CI. Co-Authored-By: Charlie Hulcher --- src/daemon/session-tracker.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/daemon/session-tracker.test.ts b/src/daemon/session-tracker.test.ts index d70ad59..08adcd5 100644 --- a/src/daemon/session-tracker.test.ts +++ b/src/daemon/session-tracker.test.ts @@ -19,7 +19,12 @@ beforeEach(async () => { afterEach(async () => { tracker.stopLaunchCleanup(); state.flush(); - await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(tmpDir, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 100, + }); }); function makeSession(overrides: Partial = {}): AgentSession {