diff --git a/src/adapters/claude-code.test.ts b/src/adapters/claude-code.test.ts index 9ee669f..4e33f1c 100644 --- a/src/adapters/claude-code.test.ts +++ b/src/adapters/claude-code.test.ts @@ -641,7 +641,8 @@ describe("ClaudeCodeAdapter", () => { describe("session lifecycle — detached processes (BUG-2, BUG-3)", () => { it("session shows running when persisted metadata has live PID", async () => { const sessionCreated = new Date("2026-02-17T10:00:00Z"); - const launchedAt = sessionCreated.toISOString(); + const now = new Date(); + const launchedAt = now.toISOString(); // Must be recent to survive 24h TTL await createFakeProject("detached-test", [ { @@ -657,8 +658,7 @@ describe("ClaudeCodeAdapter", () => { const meta: LaunchedSessionMeta = { sessionId: "detached-session-0000-000000000000", pid: 55555, - startTime: "Mon Feb 17 10:00:01 2026", - cwd: "/Users/test/detached-test", + startTime: new Date(now.getTime() + 1000).toString(), // Just after launchedAt launchedAt, }; await fs.writeFile( @@ -813,8 +813,7 @@ describe("ClaudeCodeAdapter", () => { const meta: LaunchedSessionMeta = { sessionId: "meta-notime-0000-0000-000000000000", pid: 99999, - cwd: "/Users/test/meta-no-starttime-test", - launchedAt: sessionCreated.toISOString(), + launchedAt: new Date().toISOString(), // Must be recent to survive 24h TTL }; await fs.writeFile( path.join(sessionsMetaDir, "meta-notime-0000-0000-000000000000.json"), @@ -839,6 +838,7 @@ describe("ClaudeCodeAdapter", () => { describe("session lifecycle scenarios (BUG-5)", () => { it("wrapper dies → Claude Code continues → status shows running", async () => { const sessionCreated = new Date("2026-02-17T10:00:00Z"); + const now = new Date(); await createFakeProject("wrapper-dies-test", [ { @@ -855,10 +855,8 @@ describe("ClaudeCodeAdapter", () => { const meta: LaunchedSessionMeta = { sessionId: "wrapper-dies-0000-0000-000000000000", pid: 44444, - wrapperPid: 11111, // Wrapper PID — dead - startTime: "Mon Feb 17 10:00:01 2026", - cwd: "/Users/test/wrapper-dies-test", - launchedAt: sessionCreated.toISOString(), + startTime: new Date(now.getTime() + 1000).toString(), // Just after launchedAt + launchedAt: now.toISOString(), // Must be recent to survive 24h TTL }; await fs.writeFile( path.join(sessionsMetaDir, "wrapper-dies-0000-0000-000000000000.json"), @@ -1049,6 +1047,7 @@ describe("ClaudeCodeAdapter", () => { it("session ID is not pending- when metadata has real ID", async () => { const sessionCreated = new Date("2026-02-17T10:00:00Z"); + const now = new Date(); await createFakeProject("real-id-test", [ { @@ -1064,9 +1063,8 @@ describe("ClaudeCodeAdapter", () => { const meta: LaunchedSessionMeta = { sessionId: "real-uuid-abcd-1234-5678-000000000000", pid: 12345, - startTime: "Mon Feb 17 10:00:01 2026", - cwd: "/Users/test/real-id-test", - launchedAt: sessionCreated.toISOString(), + startTime: new Date(now.getTime() + 1000).toString(), // Just after launchedAt + launchedAt: now.toISOString(), // Must be recent to survive 24h TTL }; await fs.writeFile( path.join( diff --git a/src/adapters/claude-code.ts b/src/adapters/claude-code.ts index b4adabc..33a99ce 100644 --- a/src/adapters/claude-code.ts +++ b/src/adapters/claude-code.ts @@ -24,6 +24,12 @@ import { writePromptFile, } from "../utils/prompt-file.js"; import { resolveBinaryPath } from "../utils/resolve-binary.js"; +import { + cleanupExpiredMeta, + deleteSessionMeta, + readSessionMeta, + writeSessionMeta, +} from "../utils/session-meta.js"; import { spawnWithRetry } from "../utils/spawn-with-retry.js"; const execFileAsync = promisify(execFile); @@ -45,20 +51,8 @@ export interface PidInfo { startTime?: string; } -/** Metadata persisted by launch() so status checks survive wrapper exit */ -export interface LaunchedSessionMeta { - sessionId: string; - pid: number; - /** Process start time from `ps -p -o lstart=` for PID recycling detection */ - startTime?: string; - /** The PID of the wrapper (agentctl launch) — may differ from `pid` (Claude Code process) */ - wrapperPid?: number; - cwd: string; - model?: string; - prompt?: string; - launchedAt: string; - logPath?: string; -} +// Re-export from shared utility for backward compat +export type { LaunchedSessionMeta } from "../utils/session-meta.js"; export interface ClaudeCodeAdapterOpts { claudeDir?: string; // Override ~/.claude for testing @@ -147,6 +141,9 @@ export class ClaudeCodeAdapter implements AgentAdapter { } async discover(): Promise { + // TTL-based cleanup of stale metadata files (24h) + cleanupExpiredMeta(this.sessionsMetaDir).catch(() => {}); + // Try fast path: read history.jsonl (single file, ~2ms) const historyResults = await this.discoverFromHistory(); if (historyResults) return historyResults; @@ -257,7 +254,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { if (!isRunning) { // Check persisted metadata for detached processes - const meta = await this.readSessionMeta(sessionId); + const meta = await readSessionMeta(this.sessionsMetaDir, sessionId); if (meta?.pid && this.isProcessAlive(meta.pid)) { if (meta.startTime) { const metaStartMs = new Date(meta.startTime).getTime(); @@ -266,14 +263,14 @@ export class ClaudeCodeAdapter implements AgentAdapter { isRunning = true; pid = meta.pid; } else { - await this.deleteSessionMeta(sessionId); + await deleteSessionMeta(this.sessionsMetaDir, sessionId); } } else { isRunning = true; pid = meta.pid; } } else if (meta?.pid) { - await this.deleteSessionMeta(sessionId); + await deleteSessionMeta(this.sessionsMetaDir, sessionId); } } @@ -422,7 +419,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { // Fallback: use launch log if session JSONL not found (#135) if (!jsonlPath) { - const meta = await this.readSessionMeta(sessionId); + const meta = await readSessionMeta(this.sessionsMetaDir, sessionId); if (meta?.logPath) { try { await fs.access(meta.logPath); @@ -542,16 +539,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { // Persist session metadata so status checks work after wrapper exits if (pid) { - await this.writeSessionMeta({ - sessionId, - pid, - wrapperPid: process.pid, - cwd, - model: opts.model, - prompt: opts.prompt.slice(0, 200), - launchedAt: now.toISOString(), - logPath, - }); + await writeSessionMeta(this.sessionsMetaDir, { sessionId, pid }); } const session: AgentSession = { @@ -953,7 +941,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { // 2. Check persisted session metadata (for detached processes that // may not appear in `ps aux` filtering, e.g. after wrapper exit) - const meta = await this.readSessionMeta(entry.sessionId); + const meta = await readSessionMeta(this.sessionsMetaDir, entry.sessionId); if (meta?.pid) { // Verify the persisted PID is still alive if (this.isProcessAlive(meta.pid)) { @@ -969,7 +957,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { Math.abs(currentStartMs - recordedStartMs) > 5000 ) { // Process at this PID has a different start time — recycled - await this.deleteSessionMeta(entry.sessionId); + await deleteSessionMeta(this.sessionsMetaDir, entry.sessionId); return false; } } @@ -982,7 +970,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { return true; } // Start time doesn't match — PID was recycled, clean up stale metadata - await this.deleteSessionMeta(entry.sessionId); + await deleteSessionMeta(this.sessionsMetaDir, entry.sessionId); return false; } // No start time in metadata — can't verify, assume alive @@ -990,7 +978,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { return true; } // PID is dead — clean up stale metadata - await this.deleteSessionMeta(entry.sessionId); + await deleteSessionMeta(this.sessionsMetaDir, entry.sessionId); } // 3. Fallback: check if JSONL was modified very recently (last 60s) @@ -1056,7 +1044,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { } // Check persisted metadata for detached processes - const meta = await this.readSessionMeta(entry.sessionId); + const meta = await readSessionMeta(this.sessionsMetaDir, entry.sessionId); if (meta?.pid && this.isProcessAlive(meta.pid)) { return meta.pid; } @@ -1157,80 +1145,6 @@ export class ClaudeCodeAdapter implements AgentAdapter { const session = await this.status(sessionId); return session.pid ?? null; } - - // --- Session metadata persistence --- - - /** Write session metadata to disk so status checks survive wrapper exit */ - async writeSessionMeta( - meta: Omit, - ): Promise { - await fs.mkdir(this.sessionsMetaDir, { recursive: true }); - - // Try to capture the process start time immediately - let startTime: string | undefined; - try { - const { stdout } = await execFileAsync("ps", [ - "-p", - meta.pid.toString(), - "-o", - "lstart=", - ]); - startTime = stdout.trim() || undefined; - } catch { - // Process may have already exited or ps failed - } - - const fullMeta: LaunchedSessionMeta = { ...meta, startTime }; - const metaPath = path.join(this.sessionsMetaDir, `${meta.sessionId}.json`); - await fs.writeFile(metaPath, JSON.stringify(fullMeta, null, 2)); - } - - /** Read persisted session metadata */ - async readSessionMeta( - sessionId: string, - ): Promise { - // Check exact sessionId first - const metaPath = path.join(this.sessionsMetaDir, `${sessionId}.json`); - try { - const raw = await fs.readFile(metaPath, "utf-8"); - return JSON.parse(raw) as LaunchedSessionMeta; - } catch { - // File doesn't exist or is unreadable - } - - // Scan all metadata files for one whose sessionId matches - try { - const files = await fs.readdir(this.sessionsMetaDir); - for (const file of files) { - if (!file.endsWith(".json")) continue; - try { - const raw = await fs.readFile( - path.join(this.sessionsMetaDir, file), - "utf-8", - ); - const meta = JSON.parse(raw) as LaunchedSessionMeta; - if (meta.sessionId === sessionId) return meta; - } catch { - // skip - } - } - } catch { - // Dir doesn't exist - } - return null; - } - - /** Delete stale session metadata */ - private async deleteSessionMeta(sessionId: string): Promise { - { - const metaPath = path.join(this.sessionsMetaDir, `${sessionId}.json`); - try { - await fs.unlink(metaPath); - } catch { - // File doesn't exist - } - } - } } // --- Utility functions --- diff --git a/src/adapters/codex.test.ts b/src/adapters/codex.test.ts index 66ef4f7..6fa9684 100644 --- a/src/adapters/codex.test.ts +++ b/src/adapters/codex.test.ts @@ -389,12 +389,12 @@ describe("CodexAdapter", () => { prompt: "detached test", }); + const now = new Date(); const meta: CodexSessionMeta = { sessionId: "detached-test-0000-0000-000000000000", pid: 55555, - startTime: "Thu Feb 20 10:00:01 2026", - cwd: "/tmp/detached-test", - launchedAt: "2026-02-20T10:00:00.000Z", + startTime: now.toUTCString(), + launchedAt: now.toISOString(), }; await fs.writeFile( path.join(sessionsMetaDir, "detached-test-0000-0000-000000000000.json"), @@ -421,12 +421,12 @@ describe("CodexAdapter", () => { prompt: "dead pid", }); + const nowDead = new Date(); const meta: CodexSessionMeta = { sessionId: "dead-pid-test-0000-0000-000000000000", pid: 66666, - startTime: "Thu Feb 20 10:00:01 2026", - cwd: "/tmp/dead-pid-test", - launchedAt: "2026-02-20T10:00:00.000Z", + startTime: nowDead.toUTCString(), + launchedAt: nowDead.toISOString(), }; await fs.writeFile( path.join(sessionsMetaDir, "dead-pid-test-0000-0000-000000000000.json"), @@ -447,14 +447,12 @@ describe("CodexAdapter", () => { it("sessions from metadata-only (no JSONL) are discovered", async () => { // Session launched but Codex hasn't written to ~/.codex/sessions/ yet + const nowMeta = new Date(); const meta: CodexSessionMeta = { sessionId: "meta-only-test-0000-0000-000000000000", pid: 77777, - startTime: "Thu Feb 20 10:00:01 2026", - cwd: "/tmp/meta-only", - model: "gpt-5.2-codex", - prompt: "meta only test", - launchedAt: "2026-02-20T10:00:00.000Z", + startTime: nowMeta.toUTCString(), + launchedAt: nowMeta.toISOString(), }; await fs.writeFile( path.join( @@ -474,7 +472,6 @@ describe("CodexAdapter", () => { const sessions = await adapterWithLivePid.list({ all: true }); expect(sessions).toHaveLength(1); expect(sessions[0].id).toBe("meta-only-test-0000-0000-000000000000"); - expect(sessions[0].cwd).toBe("/tmp/meta-only"); expect(sessions[0].status).toBe("running"); }); }); diff --git a/src/adapters/codex.ts b/src/adapters/codex.ts index 8683ed9..9d984b6 100644 --- a/src/adapters/codex.ts +++ b/src/adapters/codex.ts @@ -23,6 +23,7 @@ import { writePromptFile, } from "../utils/prompt-file.js"; import { resolveBinaryPath } from "../utils/resolve-binary.js"; +import { cleanupExpiredMeta, writeSessionMeta } from "../utils/session-meta.js"; import { spawnWithRetry } from "../utils/spawn-with-retry.js"; const execFileAsync = promisify(execFile); @@ -39,17 +40,10 @@ export interface CodexPidInfo { startTime?: string; } -/** Metadata persisted by launch() so status checks survive wrapper exit */ -export interface CodexSessionMeta { - sessionId: string; - pid: number; - startTime?: string; - wrapperPid?: number; - cwd: string; - model?: string; - prompt?: string; - launchedAt: string; -} +// Re-export from shared utility for backward compat +export type { LaunchedSessionMeta as CodexSessionMeta } from "../utils/session-meta.js"; + +import type { LaunchedSessionMeta as CodexSessionMeta } from "../utils/session-meta.js"; /** Parsed session info from a Codex JSONL file */ interface CodexSessionInfo { @@ -135,6 +129,7 @@ export class CodexAdapter implements AgentAdapter { } async discover(): Promise { + cleanupExpiredMeta(this.sessionsMetaDir).catch(() => {}); const runningPids = await this.getPids(); const sessionInfos = await this.discoverSessions(); const results: DiscoveredSession[] = []; @@ -306,15 +301,7 @@ export class CodexAdapter implements AgentAdapter { const sessionId = resolvedSessionId || crypto.randomUUID(); if (pid) { - await this.writeSessionMeta({ - sessionId, - pid, - wrapperPid: process.pid, - cwd, - model: opts.model, - prompt: opts.prompt.slice(0, 200), - launchedAt: now.toISOString(), - }); + await writeSessionMeta(this.sessionsMetaDir, { sessionId, pid }); } return { @@ -518,9 +505,6 @@ export class CodexAdapter implements AgentAdapter { if (sessions.some((s) => s.id === meta.sessionId)) continue; sessions.push({ id: meta.sessionId, - cwd: meta.cwd, - model: meta.model, - firstPrompt: meta.prompt, created: new Date(meta.launchedAt), modified: new Date(meta.launchedAt), filePath: "", @@ -803,60 +787,6 @@ export class CodexAdapter implements AgentAdapter { // --- Session metadata persistence --- - async writeSessionMeta( - meta: Omit, - ): Promise { - await fs.mkdir(this.sessionsMetaDir, { recursive: true }); - - let startTime: string | undefined; - try { - const { stdout } = await execFileAsync("ps", [ - "-p", - meta.pid.toString(), - "-o", - "lstart=", - ]); - startTime = stdout.trim() || undefined; - } catch { - // Process may have already exited - } - - const fullMeta: CodexSessionMeta = { ...meta, startTime }; - const metaPath = path.join(this.sessionsMetaDir, `${meta.sessionId}.json`); - await fs.writeFile(metaPath, JSON.stringify(fullMeta, null, 2)); - } - - async readSessionMeta(sessionId: string): Promise { - const metaPath = path.join(this.sessionsMetaDir, `${sessionId}.json`); - try { - const raw = await fs.readFile(metaPath, "utf-8"); - return JSON.parse(raw) as CodexSessionMeta; - } catch { - // Not found - } - - // Scan all metadata files for matching sessionId - try { - const files = await fs.readdir(this.sessionsMetaDir); - for (const file of files) { - if (!file.endsWith(".json") || file.startsWith("launch-")) continue; - try { - const raw = await fs.readFile( - path.join(this.sessionsMetaDir, file), - "utf-8", - ); - const meta = JSON.parse(raw) as CodexSessionMeta; - if (meta.sessionId === sessionId) return meta; - } catch { - // skip - } - } - } catch { - // Dir doesn't exist - } - return null; - } - /** * Synchronous-style read of session metadata (reads from cache/disk). * Used by isSessionRunning which is called in a tight loop. diff --git a/src/adapters/opencode.test.ts b/src/adapters/opencode.test.ts index c434a3b..1c32156 100644 --- a/src/adapters/opencode.test.ts +++ b/src/adapters/opencode.test.ts @@ -919,6 +919,7 @@ describe("OpenCodeAdapter", () => { describe("session lifecycle — detached processes", () => { it("session shows running when persisted metadata has live PID", async () => { const sessionCreated = new Date("2026-02-17T10:00:00Z"); + const now = new Date(); const session = makeSession({ directory: "/Users/test/detached-test", @@ -933,9 +934,8 @@ describe("OpenCodeAdapter", () => { const meta: LaunchedSessionMeta = { sessionId: session.id, pid: 55555, - startTime: "Mon Feb 17 10:00:01 2026", - cwd: "/Users/test/detached-test", - launchedAt: sessionCreated.toISOString(), + startTime: now.toISOString(), + launchedAt: now.toISOString(), }; await fs.writeFile( path.join(sessionsMetaDir, `${session.id}.json`), @@ -972,8 +972,7 @@ describe("OpenCodeAdapter", () => { sessionId: session.id, pid: 66666, startTime: "Mon Feb 17 10:00:01 2026", - cwd: "/Users/test/dead-detached", - launchedAt: sessionCreated.toISOString(), + launchedAt: new Date().toISOString(), }; await fs.writeFile( path.join(sessionsMetaDir, `${session.id}.json`), @@ -1011,8 +1010,7 @@ describe("OpenCodeAdapter", () => { sessionId: session.id, pid: 77777, startTime: "Mon Feb 17 10:00:01 2026", - cwd: "/Users/test/cleanup-test", - launchedAt: sessionCreated.toISOString(), + launchedAt: new Date().toISOString(), }; await fs.writeFile(metaPath, JSON.stringify(meta)); @@ -1046,8 +1044,7 @@ describe("OpenCodeAdapter", () => { sessionId: session.id, pid: 88888, startTime: "Sun Feb 16 08:00:00 2026", // Before session — recycled - cwd: "/Users/test/meta-recycle", - launchedAt: sessionCreated.toISOString(), + launchedAt: new Date().toISOString(), }; await fs.writeFile( path.join(sessionsMetaDir, `${session.id}.json`), @@ -1082,8 +1079,7 @@ describe("OpenCodeAdapter", () => { const meta: LaunchedSessionMeta = { sessionId: session.id, pid: 99999, - cwd: "/Users/test/no-starttime-meta", - launchedAt: sessionCreated.toISOString(), + launchedAt: new Date().toISOString(), }; await fs.writeFile( path.join(sessionsMetaDir, `${session.id}.json`), @@ -1105,6 +1101,7 @@ describe("OpenCodeAdapter", () => { it("wrapper dies → opencode continues → status shows running", async () => { const sessionCreated = new Date("2026-02-17T10:00:00Z"); + const now = new Date(); const session = makeSession({ directory: "/Users/test/wrapper-dies", @@ -1119,10 +1116,8 @@ describe("OpenCodeAdapter", () => { const meta: LaunchedSessionMeta = { sessionId: session.id, pid: 44444, - wrapperPid: 11111, - startTime: "Mon Feb 17 10:00:01 2026", - cwd: "/Users/test/wrapper-dies", - launchedAt: sessionCreated.toISOString(), + startTime: now.toISOString(), + launchedAt: now.toISOString(), }; await fs.writeFile( path.join(sessionsMetaDir, `${session.id}.json`), @@ -1165,8 +1160,7 @@ describe("OpenCodeAdapter", () => { sessionId: session.id, pid: 55555, startTime: "Mon Feb 17 10:00:01 2026", - cwd: "/Users/test/complete-test", - launchedAt: sessionCreated.toISOString(), + launchedAt: new Date().toISOString(), }; await fs.writeFile( path.join(sessionsMetaDir, `${session.id}.json`), @@ -1203,8 +1197,7 @@ describe("OpenCodeAdapter", () => { sessionId: session.id, pid: 33333, startTime: "Sun Feb 16 10:00:01 2026", - cwd: "/Users/test/pid-recycled", - launchedAt: oldSessionCreated.toISOString(), + launchedAt: new Date().toISOString(), }; await fs.writeFile( path.join(sessionsMetaDir, `${session.id}.json`), diff --git a/src/adapters/opencode.ts b/src/adapters/opencode.ts index a44b879..8f12b3d 100644 --- a/src/adapters/opencode.ts +++ b/src/adapters/opencode.ts @@ -23,6 +23,12 @@ import { writePromptFile, } from "../utils/prompt-file.js"; import { resolveBinaryPath } from "../utils/resolve-binary.js"; +import { + cleanupExpiredMeta, + deleteSessionMeta, + readSessionMeta, + writeSessionMeta, +} from "../utils/session-meta.js"; import { spawnWithRetry } from "../utils/spawn-with-retry.js"; const execFileAsync = promisify(execFile); @@ -46,19 +52,8 @@ export interface PidInfo { startTime?: string; } -/** Metadata persisted by launch() so status checks survive wrapper exit */ -export interface LaunchedSessionMeta { - sessionId: string; - pid: number; - /** Process start time from `ps -p -o lstart=` for PID recycling detection */ - startTime?: string; - /** The PID of the wrapper (agentctl launch) — may differ from `pid` (opencode process) */ - wrapperPid?: number; - cwd: string; - model?: string; - prompt?: string; - launchedAt: string; -} +// Re-export from shared utility for backward compat +export type { LaunchedSessionMeta } from "../utils/session-meta.js"; /** Shape of an OpenCode session JSON file */ export interface OpenCodeSessionFile { @@ -152,6 +147,7 @@ export class OpenCodeAdapter implements AgentAdapter { } async discover(): Promise { + cleanupExpiredMeta(this.sessionsMetaDir).catch(() => {}); const runningPids = await this.getPids(); const results: DiscoveredSession[] = []; @@ -315,7 +311,7 @@ export class OpenCodeAdapter implements AgentAdapter { let pid: number | undefined; if (isRunning) { - const meta = await this.readSessionMeta(resolved.id); + const meta = await readSessionMeta(this.sessionsMetaDir, resolved.id); if (meta?.pid && this.isProcessAlive(meta.pid)) { pid = meta.pid; } @@ -394,15 +390,7 @@ export class OpenCodeAdapter implements AgentAdapter { // Persist session metadata so status checks work after wrapper exits if (pid) { - await this.writeSessionMeta({ - sessionId, - pid, - wrapperPid: process.pid, - cwd, - model: opts.model, - prompt: opts.prompt.slice(0, 200), - launchedAt: now.toISOString(), - }); + await writeSessionMeta(this.sessionsMetaDir, { sessionId, pid }); } const session: AgentSession = { @@ -632,7 +620,7 @@ export class OpenCodeAdapter implements AgentAdapter { } // 2. Check persisted session metadata (for detached processes) - const meta = await this.readSessionMeta(sessionData.id); + const meta = await readSessionMeta(this.sessionsMetaDir, sessionData.id); if (meta?.pid) { if (this.isProcessAlive(meta.pid)) { // Cross-check: if this PID appears in runningPids with a DIFFERENT @@ -646,7 +634,7 @@ export class OpenCodeAdapter implements AgentAdapter { !Number.isNaN(recordedStartMs) && Math.abs(currentStartMs - recordedStartMs) > 5000 ) { - await this.deleteSessionMeta(sessionData.id); + await deleteSessionMeta(this.sessionsMetaDir, sessionData.id); return false; } } @@ -658,12 +646,12 @@ export class OpenCodeAdapter implements AgentAdapter { if (!Number.isNaN(metaStartMs) && metaStartMs >= sessionMs - 5000) { return true; } - await this.deleteSessionMeta(sessionData.id); + await deleteSessionMeta(this.sessionsMetaDir, sessionData.id); return false; } return true; } - await this.deleteSessionMeta(sessionData.id); + await deleteSessionMeta(this.sessionsMetaDir, sessionData.id); } // 3. Fallback: check if session was updated very recently (last 60s) @@ -694,7 +682,7 @@ export class OpenCodeAdapter implements AgentAdapter { sessionData: OpenCodeSessionFile, ): Promise { // 1. Check persisted session metadata (for sessions launched via agentctl) - const meta = await this.readSessionMeta(sessionData.id); + const meta = await readSessionMeta(this.sessionsMetaDir, sessionData.id); if (meta?.pid && this.isProcessAlive(meta.pid)) { return true; } @@ -739,7 +727,7 @@ export class OpenCodeAdapter implements AgentAdapter { } } - const meta = await this.readSessionMeta(sessionData.id); + const meta = await readSessionMeta(this.sessionsMetaDir, sessionData.id); if (meta?.pid && this.isProcessAlive(meta.pid)) { return meta.pid; } @@ -891,75 +879,6 @@ export class OpenCodeAdapter implements AgentAdapter { const session = await this.status(sessionId); return session.pid ?? null; } - - // --- Session metadata persistence --- - - async writeSessionMeta( - meta: Omit, - ): Promise { - await fs.mkdir(this.sessionsMetaDir, { recursive: true }); - - let startTime: string | undefined; - try { - const { stdout } = await execFileAsync("ps", [ - "-p", - meta.pid.toString(), - "-o", - "lstart=", - ]); - startTime = stdout.trim() || undefined; - } catch { - // Process may have already exited or ps failed - } - - const fullMeta: LaunchedSessionMeta = { ...meta, startTime }; - const metaPath = path.join(this.sessionsMetaDir, `${meta.sessionId}.json`); - await fs.writeFile(metaPath, JSON.stringify(fullMeta, null, 2)); - } - - async readSessionMeta( - sessionId: string, - ): Promise { - const metaPath = path.join(this.sessionsMetaDir, `${sessionId}.json`); - try { - const raw = await fs.readFile(metaPath, "utf-8"); - return JSON.parse(raw) as LaunchedSessionMeta; - } catch { - // File doesn't exist or is unreadable - } - - // Scan all metadata files for one whose sessionId matches - try { - const files = await fs.readdir(this.sessionsMetaDir); - for (const file of files) { - if (!file.endsWith(".json")) continue; - try { - const raw = await fs.readFile( - path.join(this.sessionsMetaDir, file), - "utf-8", - ); - const meta = JSON.parse(raw) as LaunchedSessionMeta; - if (meta.sessionId === sessionId) return meta; - } catch { - // skip - } - } - } catch { - // Dir doesn't exist - } - return null; - } - - private async deleteSessionMeta(sessionId: string): Promise { - { - const metaPath = path.join(this.sessionsMetaDir, `${sessionId}.json`); - try { - await fs.unlink(metaPath); - } catch { - // File doesn't exist - } - } - } } // --- Utility functions --- diff --git a/src/adapters/pi-rust.test.ts b/src/adapters/pi-rust.test.ts index e10d7fb..370cf19 100644 --- a/src/adapters/pi-rust.test.ts +++ b/src/adapters/pi-rust.test.ts @@ -2,10 +2,11 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { LaunchedSessionMeta } from "../utils/session-meta.js"; +import { readSessionMeta } from "../utils/session-meta.js"; import { decodeProjDir, encodeProjDir, - type LaunchedSessionMeta, type PidInfo, PiRustAdapter, } from "./pi-rust.js"; @@ -952,7 +953,7 @@ describe("PiRustAdapter", () => { describe("session lifecycle — detached processes", () => { it("session shows running when persisted metadata has live PID", async () => { - const sessionCreated = new Date("2026-02-17T10:00:00Z"); + const sessionCreated = new Date(); await createFakeSession({ id: "detached-session-1111-2222-333333333333", @@ -960,12 +961,12 @@ describe("PiRustAdapter", () => { timestamp: sessionCreated.toISOString(), }); + const launchedAt = new Date(); const meta: LaunchedSessionMeta = { sessionId: "detached-session-1111-2222-333333333333", pid: 55555, - startTime: "Mon Feb 17 10:00:01 2026", - cwd: "/Users/test/detached-test", - launchedAt: sessionCreated.toISOString(), + startTime: launchedAt.toString(), + launchedAt: launchedAt.toISOString(), }; await fs.writeFile( path.join( @@ -989,7 +990,7 @@ describe("PiRustAdapter", () => { }); it("session shows stopped when persisted PID is dead", async () => { - const sessionCreated = new Date("2026-02-17T10:00:00Z"); + const sessionCreated = new Date(); await createFakeSession({ id: "dead-detached-1111-2222-333333333333", @@ -1001,8 +1002,7 @@ describe("PiRustAdapter", () => { sessionId: "dead-detached-1111-2222-333333333333", pid: 66666, startTime: "Mon Feb 17 10:00:01 2026", - cwd: "/Users/test/dead-detached-test", - launchedAt: sessionCreated.toISOString(), + launchedAt: new Date().toISOString(), }; await fs.writeFile( path.join(sessionsMetaDir, "dead-detached-1111-2222-333333333333.json"), @@ -1023,7 +1023,7 @@ describe("PiRustAdapter", () => { }); it("cleans up stale metadata when PID is dead", async () => { - const sessionCreated = new Date("2026-02-17T10:00:00Z"); + const sessionCreated = new Date(); await createFakeSession({ id: "cleanup-session-1111-2222-333333333333", @@ -1039,8 +1039,7 @@ describe("PiRustAdapter", () => { sessionId: "cleanup-session-1111-2222-333333333333", pid: 77777, startTime: "Mon Feb 17 10:00:01 2026", - cwd: "/Users/test/cleanup-test", - launchedAt: sessionCreated.toISOString(), + launchedAt: new Date().toISOString(), }; await fs.writeFile(metaPath, JSON.stringify(meta)); @@ -1056,7 +1055,7 @@ describe("PiRustAdapter", () => { }); it("detects PID recycling in persisted metadata via start time", async () => { - const sessionCreated = new Date("2026-02-17T10:00:00Z"); + const sessionCreated = new Date(); await createFakeSession({ id: "meta-recycle-1111-2222-333333333333", @@ -1068,8 +1067,7 @@ describe("PiRustAdapter", () => { sessionId: "meta-recycle-1111-2222-333333333333", pid: 88888, startTime: "Sun Feb 16 08:00:00 2026", - cwd: "/Users/test/meta-recycle-test", - launchedAt: sessionCreated.toISOString(), + launchedAt: new Date().toISOString(), }; await fs.writeFile( path.join(sessionsMetaDir, "meta-recycle-1111-2222-333333333333.json"), @@ -1089,7 +1087,7 @@ describe("PiRustAdapter", () => { }); it("old metadata without startTime but with live PID assumes running", async () => { - const sessionCreated = new Date("2026-02-17T10:00:00Z"); + const sessionCreated = new Date(); await createFakeSession({ id: "meta-notime-1111-2222-333333333333", @@ -1100,8 +1098,7 @@ describe("PiRustAdapter", () => { const meta: LaunchedSessionMeta = { sessionId: "meta-notime-1111-2222-333333333333", pid: 99999, - cwd: "/Users/test/meta-notime-test", - launchedAt: sessionCreated.toISOString(), + launchedAt: new Date().toISOString(), }; await fs.writeFile( path.join(sessionsMetaDir, "meta-notime-1111-2222-333333333333.json"), @@ -1122,7 +1119,7 @@ describe("PiRustAdapter", () => { }); it("wrapper dies → pi-rust continues → status shows running", async () => { - const sessionCreated = new Date("2026-02-17T10:00:00Z"); + const sessionCreated = new Date(); await createFakeSession({ id: "wrapper-dies-1111-2222-333333333333", @@ -1130,13 +1127,12 @@ describe("PiRustAdapter", () => { timestamp: sessionCreated.toISOString(), }); + const launchedAt = new Date(); const meta: LaunchedSessionMeta = { sessionId: "wrapper-dies-1111-2222-333333333333", pid: 44444, - wrapperPid: 11111, - startTime: "Mon Feb 17 10:00:01 2026", - cwd: "/Users/test/wrapper-dies-test", - launchedAt: sessionCreated.toISOString(), + startTime: launchedAt.toString(), + launchedAt: launchedAt.toISOString(), }; await fs.writeFile( path.join(sessionsMetaDir, "wrapper-dies-1111-2222-333333333333.json"), @@ -1157,7 +1153,7 @@ describe("PiRustAdapter", () => { }); it("pi-rust completes → status shows stopped", async () => { - const sessionCreated = new Date("2026-02-17T10:00:00Z"); + const sessionCreated = new Date(); await createFakeSession({ id: "pi-complete-1111-2222-333333333333", @@ -1167,7 +1163,7 @@ describe("PiRustAdapter", () => { { type: "message", id: "a1", - timestamp: new Date("2026-02-17T10:30:00Z").toISOString(), + timestamp: new Date().toISOString(), message: { role: "assistant", content: [{ type: "text", text: "All done!" }], @@ -1181,8 +1177,7 @@ describe("PiRustAdapter", () => { sessionId: "pi-complete-1111-2222-333333333333", pid: 55555, startTime: "Mon Feb 17 10:00:01 2026", - cwd: "/Users/test/pi-complete-test", - launchedAt: sessionCreated.toISOString(), + launchedAt: new Date().toISOString(), }; await fs.writeFile( path.join(sessionsMetaDir, "pi-complete-1111-2222-333333333333.json"), @@ -1203,7 +1198,7 @@ describe("PiRustAdapter", () => { }); it("old PID recycled → old session shows stopped", async () => { - const oldSessionCreated = new Date("2026-02-16T10:00:00Z"); + const oldSessionCreated = new Date(); await createFakeSession({ id: "recycled-victim-1111-2222-333333333333", @@ -1215,8 +1210,7 @@ describe("PiRustAdapter", () => { sessionId: "recycled-victim-1111-2222-333333333333", pid: 33333, startTime: "Sun Feb 16 10:00:01 2026", - cwd: "/Users/test/pid-recycled-scenario", - launchedAt: oldSessionCreated.toISOString(), + launchedAt: new Date().toISOString(), }; await fs.writeFile( path.join( @@ -1249,7 +1243,7 @@ describe("PiRustAdapter", () => { describe("session metadata persistence", () => { it("readSessionMeta returns null for nonexistent session", async () => { - const meta = await adapter.readSessionMeta("nonexistent"); + const meta = await readSessionMeta(sessionsMetaDir, "nonexistent"); expect(meta).toBeNull(); }); @@ -1258,7 +1252,6 @@ describe("PiRustAdapter", () => { sessionId: "test-meta-1111-2222-333333333333", pid: 12345, startTime: "Mon Feb 17 10:00:01 2026", - cwd: "/Users/test/meta-project", launchedAt: new Date().toISOString(), }; await fs.writeFile( @@ -1266,7 +1259,8 @@ describe("PiRustAdapter", () => { JSON.stringify(metaData), ); - const read = await adapter.readSessionMeta( + const read = await readSessionMeta( + sessionsMetaDir, "test-meta-1111-2222-333333333333", ); expect(read).not.toBeNull(); @@ -1278,7 +1272,6 @@ describe("PiRustAdapter", () => { const metaData: LaunchedSessionMeta = { sessionId: "target-session-1111-2222-333333333333", pid: 12345, - cwd: "/test", launchedAt: new Date().toISOString(), }; // Write under a different filename @@ -1287,7 +1280,8 @@ describe("PiRustAdapter", () => { JSON.stringify(metaData), ); - const read = await adapter.readSessionMeta( + const read = await readSessionMeta( + sessionsMetaDir, "target-session-1111-2222-333333333333", ); expect(read).not.toBeNull(); diff --git a/src/adapters/pi-rust.ts b/src/adapters/pi-rust.ts index cecf3d6..58bb55a 100644 --- a/src/adapters/pi-rust.ts +++ b/src/adapters/pi-rust.ts @@ -23,6 +23,12 @@ import { writePromptFile, } from "../utils/prompt-file.js"; import { resolveBinaryPath } from "../utils/resolve-binary.js"; +import { + cleanupExpiredMeta, + deleteSessionMeta, + readSessionMeta, + writeSessionMeta, +} from "../utils/session-meta.js"; import { spawnWithRetry } from "../utils/spawn-with-retry.js"; const execFileAsync = promisify(execFile); @@ -40,19 +46,8 @@ export interface PidInfo { startTime?: string; } -/** Metadata persisted by launch() so status checks survive wrapper exit */ -export interface LaunchedSessionMeta { - sessionId: string; - pid: number; - /** Process start time from `ps -p -o lstart=` for PID recycling detection */ - startTime?: string; - /** The PID of the wrapper (agentctl launch) — may differ from `pid` (pi-rust process) */ - wrapperPid?: number; - cwd: string; - model?: string; - prompt?: string; - launchedAt: string; -} +// Re-export from shared utility for backward compat +export type { LaunchedSessionMeta } from "../utils/session-meta.js"; export interface PiRustAdapterOpts { sessionDir?: string; // Override ~/.pi/agent/sessions for testing @@ -140,6 +135,7 @@ export class PiRustAdapter implements AgentAdapter { } async discover(): Promise { + cleanupExpiredMeta(this.sessionsMetaDir).catch(() => {}); const runningPids = await this.getPids(); const results: DiscoveredSession[] = []; @@ -405,15 +401,7 @@ export class PiRustAdapter implements AgentAdapter { // Persist session metadata so status checks work after wrapper exits if (pid) { - await this.writeSessionMeta({ - sessionId, - pid, - wrapperPid: process.pid, - cwd, - model: opts.model, - prompt: opts.prompt.slice(0, 200), - launchedAt: now.toISOString(), - }); + await writeSessionMeta(this.sessionsMetaDir, { sessionId, pid }); } const session: AgentSession = { @@ -683,7 +671,7 @@ export class PiRustAdapter implements AgentAdapter { } // 2. Check persisted session metadata - const meta = await this.readSessionMeta(header.id); + const meta = await readSessionMeta(this.sessionsMetaDir, header.id); if (meta?.pid) { if (this.isProcessAlive(meta.pid)) { const pidInfo = runningPids.get(meta.pid); @@ -695,7 +683,7 @@ export class PiRustAdapter implements AgentAdapter { !Number.isNaN(recordedStartMs) && Math.abs(currentStartMs - recordedStartMs) > 5000 ) { - await this.deleteSessionMeta(header.id); + await deleteSessionMeta(this.sessionsMetaDir, header.id); return false; } } @@ -706,12 +694,12 @@ export class PiRustAdapter implements AgentAdapter { if (!Number.isNaN(metaStartMs) && metaStartMs >= sessionMs - 5000) { return true; } - await this.deleteSessionMeta(header.id); + await deleteSessionMeta(this.sessionsMetaDir, header.id); return false; } return true; } - await this.deleteSessionMeta(header.id); + await deleteSessionMeta(this.sessionsMetaDir, header.id); } return false; @@ -744,7 +732,7 @@ export class PiRustAdapter implements AgentAdapter { } } - const meta = await this.readSessionMeta(header.id); + const meta = await readSessionMeta(this.sessionsMetaDir, header.id); if (meta?.pid && this.isProcessAlive(meta.pid)) { return meta.pid; } @@ -887,74 +875,6 @@ export class PiRustAdapter implements AgentAdapter { const session = await this.status(sessionId); return session.pid ?? null; } - - // --- Session metadata persistence --- - - async writeSessionMeta( - meta: Omit, - ): Promise { - await fs.mkdir(this.sessionsMetaDir, { recursive: true }); - - let startTime: string | undefined; - try { - const { stdout } = await execFileAsync("ps", [ - "-p", - meta.pid.toString(), - "-o", - "lstart=", - ]); - startTime = stdout.trim() || undefined; - } catch { - // Process may have already exited or ps failed - } - - const fullMeta: LaunchedSessionMeta = { ...meta, startTime }; - const metaPath = path.join(this.sessionsMetaDir, `${meta.sessionId}.json`); - await fs.writeFile(metaPath, JSON.stringify(fullMeta, null, 2)); - } - - async readSessionMeta( - sessionId: string, - ): Promise { - const metaPath = path.join(this.sessionsMetaDir, `${sessionId}.json`); - try { - const raw = await fs.readFile(metaPath, "utf-8"); - return JSON.parse(raw) as LaunchedSessionMeta; - } catch { - // File doesn't exist or is unreadable - } - - try { - const files = await fs.readdir(this.sessionsMetaDir); - for (const file of files) { - if (!file.endsWith(".json")) continue; - try { - const raw = await fs.readFile( - path.join(this.sessionsMetaDir, file), - "utf-8", - ); - const meta = JSON.parse(raw) as LaunchedSessionMeta; - if (meta.sessionId === sessionId) return meta; - } catch { - // skip - } - } - } catch { - // Dir doesn't exist - } - return null; - } - - private async deleteSessionMeta(sessionId: string): Promise { - { - const metaPath = path.join(this.sessionsMetaDir, `${sessionId}.json`); - try { - await fs.unlink(metaPath); - } catch { - // File doesn't exist - } - } - } } // --- Utility functions --- diff --git a/src/adapters/pi.test.ts b/src/adapters/pi.test.ts index a204817..e1c8aa5 100644 --- a/src/adapters/pi.test.ts +++ b/src/adapters/pi.test.ts @@ -2,7 +2,12 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { type LaunchedSessionMeta, PiAdapter, type PidInfo } from "./pi.js"; +import { + type LaunchedSessionMeta, + readSessionMeta, + writeSessionMeta, +} from "../utils/session-meta.js"; +import { PiAdapter, type PidInfo } from "./pi.js"; let tmpDir: string; let piDir: string; @@ -859,7 +864,7 @@ describe("PiAdapter", () => { describe("session lifecycle — detached processes", () => { it("session shows running when persisted metadata has live PID", async () => { - const sessionCreated = new Date("2026-02-17T10:00:00Z"); + const sessionCreated = new Date(); const launchedAt = sessionCreated.toISOString(); await createFakePiSession({ @@ -872,8 +877,7 @@ describe("PiAdapter", () => { const meta: LaunchedSessionMeta = { sessionId: "detached-session-0000-000000000000", pid: 55555, - startTime: "Mon Feb 17 10:00:01 2026", - cwd: "/Users/test/detached-test", + startTime: new Date().toString(), launchedAt, }; await fs.writeFile( @@ -905,9 +909,8 @@ describe("PiAdapter", () => { const meta: LaunchedSessionMeta = { sessionId: "dead-detached-0000-0000-000000000000", pid: 66666, - startTime: "Mon Feb 17 10:00:01 2026", - cwd: "/Users/test/dead-detached-test", - launchedAt: new Date("2026-02-17T10:00:00Z").toISOString(), + startTime: new Date().toString(), + launchedAt: new Date().toISOString(), }; await fs.writeFile( path.join(sessionsMetaDir, "dead-detached-0000-0000-000000000000.json"), @@ -941,9 +944,8 @@ describe("PiAdapter", () => { const meta: LaunchedSessionMeta = { sessionId: "cleanup-session-0000-000000000000", pid: 77777, - startTime: "Mon Feb 17 10:00:01 2026", - cwd: "/Users/test/cleanup-test", - launchedAt: new Date("2026-02-17T10:00:00Z").toISOString(), + startTime: new Date().toString(), + launchedAt: new Date().toISOString(), }; await fs.writeFile(metaPath, JSON.stringify(meta)); @@ -971,8 +973,7 @@ describe("PiAdapter", () => { sessionId: "meta-recycle-0000-0000-000000000000", pid: 88888, startTime: "Sun Feb 16 08:00:00 2026", - cwd: "/Users/test/meta-recycle-test", - launchedAt: new Date("2026-02-17T10:00:00Z").toISOString(), + launchedAt: new Date().toISOString(), }; await fs.writeFile( path.join(sessionsMetaDir, "meta-recycle-0000-0000-000000000000.json"), @@ -1001,8 +1002,7 @@ describe("PiAdapter", () => { const meta: LaunchedSessionMeta = { sessionId: "meta-notime-0000-0000-000000000000", pid: 99999, - cwd: "/Users/test/meta-no-starttime-test", - launchedAt: new Date("2026-02-17T10:00:00Z").toISOString(), + launchedAt: new Date().toISOString(), }; await fs.writeFile( path.join(sessionsMetaDir, "meta-notime-0000-0000-000000000000.json"), @@ -1034,10 +1034,8 @@ describe("PiAdapter", () => { const meta: LaunchedSessionMeta = { sessionId: "wrapper-dies-0000-0000-000000000000", pid: 44444, - wrapperPid: 11111, - startTime: "Mon Feb 17 10:00:01 2026", - cwd: "/Users/test/wrapper-dies-test", - launchedAt: new Date("2026-02-17T10:00:00Z").toISOString(), + startTime: new Date().toString(), + launchedAt: new Date().toISOString(), }; await fs.writeFile( path.join(sessionsMetaDir, "wrapper-dies-0000-0000-000000000000.json"), @@ -1074,9 +1072,8 @@ describe("PiAdapter", () => { const meta: LaunchedSessionMeta = { sessionId: "pi-complete-0000-0000-000000000000", pid: 55555, - startTime: "Mon Feb 17 10:00:01 2026", - cwd: "/Users/test/pi-complete-test", - launchedAt: new Date("2026-02-17T10:00:00Z").toISOString(), + startTime: new Date().toString(), + launchedAt: new Date().toISOString(), }; await fs.writeFile( path.join(sessionsMetaDir, "pi-complete-0000-0000-000000000000.json"), @@ -1107,8 +1104,7 @@ describe("PiAdapter", () => { sessionId: "recycled-victim-0000-000000000000", pid: 33333, startTime: "Sun Feb 16 10:00:01 2026", - cwd: "/Users/test/pid-recycled-scenario", - launchedAt: new Date("2026-02-16T10:00:00Z").toISOString(), + launchedAt: new Date().toISOString(), }; await fs.writeFile( path.join(sessionsMetaDir, "recycled-victim-0000-000000000000.json"), @@ -1146,9 +1142,8 @@ describe("PiAdapter", () => { const meta: LaunchedSessionMeta = { sessionId: "real-uuid-abcd-1234-5678-000000000000", pid: 12345, - startTime: "Mon Feb 17 10:00:01 2026", - cwd: "/Users/test/real-id-test", - launchedAt: new Date("2026-02-17T10:00:00Z").toISOString(), + startTime: new Date().toString(), + launchedAt: new Date().toISOString(), }; await fs.writeFile( path.join( @@ -1175,24 +1170,22 @@ describe("PiAdapter", () => { describe("session metadata persistence", () => { it("writeSessionMeta and readSessionMeta round-trip", async () => { - await adapter.writeSessionMeta({ + await writeSessionMeta(sessionsMetaDir, { sessionId: "roundtrip-session-id", pid: 12345, - cwd: "/Users/test/roundtrip", - prompt: "test prompt", - launchedAt: new Date().toISOString(), }); - const meta = await adapter.readSessionMeta("roundtrip-session-id"); + const meta = await readSessionMeta( + sessionsMetaDir, + "roundtrip-session-id", + ); expect(meta).not.toBeNull(); expect(meta?.sessionId).toBe("roundtrip-session-id"); expect(meta?.pid).toBe(12345); - expect(meta?.cwd).toBe("/Users/test/roundtrip"); - expect(meta?.prompt).toBe("test prompt"); }); it("readSessionMeta returns null for nonexistent session", async () => { - const meta = await adapter.readSessionMeta("nonexistent"); + const meta = await readSessionMeta(sessionsMetaDir, "nonexistent"); expect(meta).toBeNull(); }); @@ -1204,12 +1197,14 @@ describe("PiAdapter", () => { JSON.stringify({ sessionId: "scan-target-session", pid: 11111, - cwd: "/test", launchedAt: new Date().toISOString(), }), ); - const meta = await adapter.readSessionMeta("scan-target-session"); + const meta = await readSessionMeta( + sessionsMetaDir, + "scan-target-session", + ); expect(meta).not.toBeNull(); expect(meta?.sessionId).toBe("scan-target-session"); }); diff --git a/src/adapters/pi.ts b/src/adapters/pi.ts index fee538a..61fdaba 100644 --- a/src/adapters/pi.ts +++ b/src/adapters/pi.ts @@ -23,8 +23,17 @@ import { writePromptFile, } from "../utils/prompt-file.js"; import { resolveBinaryPath } from "../utils/resolve-binary.js"; +import { + cleanupExpiredMeta, + deleteSessionMeta, + readSessionMeta, + writeSessionMeta, +} from "../utils/session-meta.js"; import { spawnWithRetry } from "../utils/spawn-with-retry.js"; +// Re-export from shared utility for backward compat +export type { LaunchedSessionMeta } from "../utils/session-meta.js"; + const execFileAsync = promisify(execFile); const DEFAULT_PI_DIR = path.join(os.homedir(), ".pi"); @@ -40,20 +49,6 @@ export interface PidInfo { startTime?: string; } -/** Metadata persisted by launch() so status checks survive wrapper exit */ -export interface LaunchedSessionMeta { - sessionId: string; - pid: number; - /** Process start time from `ps -p -o lstart=` for PID recycling detection */ - startTime?: string; - /** The PID of the wrapper (agentctl launch) — may differ from `pid` (pi process) */ - wrapperPid?: number; - cwd: string; - model?: string; - prompt?: string; - launchedAt: string; -} - export interface PiAdapterOpts { piDir?: string; // Override ~/.pi for testing sessionsMetaDir?: string; // Override metadata dir for testing @@ -143,6 +138,7 @@ export class PiAdapter implements AgentAdapter { } async discover(): Promise { + cleanupExpiredMeta(this.sessionsMetaDir).catch(() => {}); const runningPids = await this.getPids(); const discovered = await this.discoverSessions(); const results: DiscoveredSession[] = []; @@ -233,7 +229,7 @@ export class PiAdapter implements AgentAdapter { // Fallback: read from the launch log file if (!disc) { - const meta = await this.readSessionMeta(sessionId); + const meta = await readSessionMeta(this.sessionsMetaDir, sessionId); if (meta?.sessionId) { // Try to find the session by the metadata's session ID const resolved = await this.findSession(meta.sessionId); @@ -291,7 +287,7 @@ export class PiAdapter implements AgentAdapter { private async getLogPathForSession( sessionId: string, ): Promise { - const meta = await this.readSessionMeta(sessionId); + const meta = await readSessionMeta(this.sessionsMetaDir, sessionId); if (!meta) return null; // The log path is stored in the launch metadata directory const logPath = path.join( @@ -389,15 +385,7 @@ export class PiAdapter implements AgentAdapter { // Persist session metadata so status checks work after wrapper exits if (pid) { - await this.writeSessionMeta({ - sessionId, - pid, - wrapperPid: process.pid, - cwd, - model: opts.model, - prompt: opts.prompt.slice(0, 200), - launchedAt: now.toISOString(), - }); + await writeSessionMeta(this.sessionsMetaDir, { sessionId, pid }); } const session: AgentSession = { @@ -761,7 +749,7 @@ export class PiAdapter implements AgentAdapter { // 2. Check persisted session metadata (for detached processes that // may not appear in `ps aux` filtering, e.g. after wrapper exit) - const meta = await this.readSessionMeta(disc.sessionId); + const meta = await readSessionMeta(this.sessionsMetaDir, disc.sessionId); if (meta?.pid) { // Verify the persisted PID is still alive if (this.isProcessAlive(meta.pid)) { @@ -777,7 +765,7 @@ export class PiAdapter implements AgentAdapter { Math.abs(currentStartMs - recordedStartMs) > 5000 ) { // Process at this PID has a different start time — recycled - await this.deleteSessionMeta(disc.sessionId); + await deleteSessionMeta(this.sessionsMetaDir, disc.sessionId); return false; } } @@ -790,14 +778,14 @@ export class PiAdapter implements AgentAdapter { return true; } // Start time doesn't match — PID was recycled, clean up stale metadata - await this.deleteSessionMeta(disc.sessionId); + await deleteSessionMeta(this.sessionsMetaDir, disc.sessionId); return false; } // No start time in metadata — can't verify, assume alive return true; } // PID is dead — clean up stale metadata - await this.deleteSessionMeta(disc.sessionId); + await deleteSessionMeta(this.sessionsMetaDir, disc.sessionId); } // 3. Fallback: check if JSONL was modified very recently (last 60s) @@ -855,7 +843,7 @@ export class PiAdapter implements AgentAdapter { } // Check persisted metadata for detached processes - const meta = await this.readSessionMeta(disc.sessionId); + const meta = await readSessionMeta(this.sessionsMetaDir, disc.sessionId); if (meta?.pid && this.isProcessAlive(meta.pid)) { return meta.pid; } @@ -958,80 +946,6 @@ export class PiAdapter implements AgentAdapter { const session = await this.status(sessionId); return session.pid ?? null; } - - // --- Session metadata persistence --- - - /** Write session metadata to disk so status checks survive wrapper exit */ - async writeSessionMeta( - meta: Omit, - ): Promise { - await fs.mkdir(this.sessionsMetaDir, { recursive: true }); - - // Try to capture the process start time immediately - let startTime: string | undefined; - try { - const { stdout } = await execFileAsync("ps", [ - "-p", - meta.pid.toString(), - "-o", - "lstart=", - ]); - startTime = stdout.trim() || undefined; - } catch { - // Process may have already exited or ps failed - } - - const fullMeta: LaunchedSessionMeta = { ...meta, startTime }; - const metaPath = path.join(this.sessionsMetaDir, `${meta.sessionId}.json`); - await fs.writeFile(metaPath, JSON.stringify(fullMeta, null, 2)); - } - - /** Read persisted session metadata */ - async readSessionMeta( - sessionId: string, - ): Promise { - // Check exact sessionId first - const metaPath = path.join(this.sessionsMetaDir, `${sessionId}.json`); - try { - const raw = await fs.readFile(metaPath, "utf-8"); - return JSON.parse(raw) as LaunchedSessionMeta; - } catch { - // File doesn't exist or is unreadable - } - - // Scan all metadata files for one whose sessionId matches - try { - const files = await fs.readdir(this.sessionsMetaDir); - for (const file of files) { - if (!file.endsWith(".json")) continue; - try { - const raw = await fs.readFile( - path.join(this.sessionsMetaDir, file), - "utf-8", - ); - const m = JSON.parse(raw) as LaunchedSessionMeta; - if (m.sessionId === sessionId) return m; - } catch { - // skip - } - } - } catch { - // Dir doesn't exist - } - return null; - } - - /** Delete stale session metadata */ - private async deleteSessionMeta(sessionId: string): Promise { - { - const metaPath = path.join(this.sessionsMetaDir, `${sessionId}.json`); - try { - await fs.unlink(metaPath); - } catch { - // File doesn't exist - } - } - } } // --- Utility functions --- diff --git a/src/daemon/lock-manager.test.ts b/src/daemon/lock-manager.test.ts index 3cbf07f..60a2aec 100644 --- a/src/daemon/lock-manager.test.ts +++ b/src/daemon/lock-manager.test.ts @@ -27,15 +27,16 @@ describe("LockManager", () => { }); it("returns auto lock if directory is auto-locked", () => { - lockManager.autoLock("/tmp/locked", "session-1"); + lockManager.autoLock("/tmp/locked", 1001, "session-1"); const lock = lockManager.check("/tmp/locked"); expect(lock).toBeDefined(); expect(lock?.type).toBe("auto"); + expect(lock?.pid).toBe(1001); expect(lock?.sessionId).toBe("session-1"); }); it("returns manual lock with precedence over auto lock", () => { - lockManager.autoLock("/tmp/locked", "session-1"); + lockManager.autoLock("/tmp/locked", 1001, "session-1"); lockManager.manualLock("/tmp/locked", "user", "testing"); const lock = lockManager.check("/tmp/locked"); expect(lock).toBeDefined(); @@ -45,16 +46,17 @@ describe("LockManager", () => { }); describe("autoLock", () => { - it("creates auto lock for directory", () => { - const lock = lockManager.autoLock("/tmp/dir", "s1"); + it("creates auto lock for directory by PID", () => { + const lock = lockManager.autoLock("/tmp/dir", 1001, "s1"); expect(lock.type).toBe("auto"); expect(lock.directory).toBe("/tmp/dir"); + expect(lock.pid).toBe(1001); expect(lock.sessionId).toBe("s1"); }); - it("is idempotent for same session", () => { - lockManager.autoLock("/tmp/dir", "s1"); - lockManager.autoLock("/tmp/dir", "s1"); + it("is idempotent for same PID+directory", () => { + lockManager.autoLock("/tmp/dir", 1001, "s1"); + lockManager.autoLock("/tmp/dir", 1001, "s1"); const locks = lockManager.listAll(); const autoLocks = locks.filter( (l) => l.directory === "/tmp/dir" && l.type === "auto", @@ -62,9 +64,9 @@ describe("LockManager", () => { expect(autoLocks).toHaveLength(1); }); - it("allows multiple auto-locks for same dir by different sessions", () => { - lockManager.autoLock("/tmp/dir", "s1"); - lockManager.autoLock("/tmp/dir", "s2"); + it("allows multiple auto-locks for same dir by different PIDs", () => { + lockManager.autoLock("/tmp/dir", 1001, "s1"); + lockManager.autoLock("/tmp/dir", 1002, "s2"); const locks = lockManager.listAll(); const autoLocks = locks.filter( (l) => l.directory === "/tmp/dir" && l.type === "auto", @@ -73,17 +75,35 @@ describe("LockManager", () => { }); }); - describe("autoUnlock", () => { - it("removes auto lock for session", () => { - lockManager.autoLock("/tmp/dir1", "s1"); - lockManager.autoLock("/tmp/dir2", "s1"); + describe("autoUnlockByPid", () => { + it("removes auto locks for a PID", () => { + lockManager.autoLock("/tmp/dir1", 1001, "s1"); + lockManager.autoLock("/tmp/dir2", 1001, "s1"); + lockManager.autoUnlockByPid(1001); + expect(lockManager.listAll()).toHaveLength(0); + }); + + it("does not remove locks for other PIDs", () => { + lockManager.autoLock("/tmp/dir", 1001, "s1"); + lockManager.autoLock("/tmp/dir", 1002, "s2"); + lockManager.autoUnlockByPid(1001); + const locks = lockManager.listAll(); + expect(locks).toHaveLength(1); + expect(locks[0].pid).toBe(1002); + }); + }); + + describe("autoUnlock (by sessionId)", () => { + it("removes auto lock by session ID", () => { + lockManager.autoLock("/tmp/dir1", 1001, "s1"); + lockManager.autoLock("/tmp/dir2", 1001, "s1"); lockManager.autoUnlock("s1"); expect(lockManager.listAll()).toHaveLength(0); }); it("does not remove locks for other sessions", () => { - lockManager.autoLock("/tmp/dir", "s1"); - lockManager.autoLock("/tmp/dir", "s2"); + lockManager.autoLock("/tmp/dir", 1001, "s1"); + lockManager.autoLock("/tmp/dir", 1002, "s2"); lockManager.autoUnlock("s1"); const locks = lockManager.listAll(); expect(locks).toHaveLength(1); @@ -91,6 +111,40 @@ describe("LockManager", () => { }); }); + describe("cleanupDeadLocks", () => { + it("releases locks for dead PIDs", () => { + lockManager.autoLock("/tmp/dir1", 1001, "s1"); + lockManager.autoLock("/tmp/dir2", 1002, "s2"); + const dead = lockManager.cleanupDeadLocks(() => false); + expect(dead).toContain(1001); + expect(dead).toContain(1002); + expect(lockManager.listAll()).toHaveLength(0); + }); + + it("keeps locks for alive PIDs", () => { + lockManager.autoLock("/tmp/dir", 1001, "s1"); + const dead = lockManager.cleanupDeadLocks(() => true); + expect(dead).toHaveLength(0); + expect(lockManager.listAll()).toHaveLength(1); + }); + + it("does not affect manual locks", () => { + lockManager.autoLock("/tmp/a", 1001, "s1"); + lockManager.manualLock("/tmp/b", "user", "reason"); + const dead = lockManager.cleanupDeadLocks(() => false); + expect(dead).toContain(1001); + expect(lockManager.listAll()).toHaveLength(1); + expect(lockManager.listAll()[0].type).toBe("manual"); + }); + + it("deduplicates returned PIDs", () => { + lockManager.autoLock("/tmp/dir1", 1001, "s1"); + lockManager.autoLock("/tmp/dir2", 1001, "s1"); + const dead = lockManager.cleanupDeadLocks(() => false); + expect(dead).toEqual([1001]); + }); + }); + describe("manualLock", () => { it("creates manual lock", () => { const lock = lockManager.manualLock("/tmp/dir", "user", "reason"); @@ -107,7 +161,7 @@ describe("LockManager", () => { }); it("allows manual lock over auto-lock", () => { - lockManager.autoLock("/tmp/dir", "s1"); + lockManager.autoLock("/tmp/dir", 1001, "s1"); const lock = lockManager.manualLock("/tmp/dir", "user", "override"); expect(lock.type).toBe("manual"); }); @@ -127,7 +181,7 @@ describe("LockManager", () => { }); it("does not remove auto locks", () => { - lockManager.autoLock("/tmp/dir", "s1"); + lockManager.autoLock("/tmp/dir", 1001, "s1"); expect(() => lockManager.manualUnlock("/tmp/dir")).toThrow( "No manual lock", ); @@ -137,7 +191,7 @@ describe("LockManager", () => { describe("listAll", () => { it("returns all locks", () => { - lockManager.autoLock("/tmp/a", "s1"); + lockManager.autoLock("/tmp/a", 1001, "s1"); lockManager.manualLock("/tmp/b", "user", "reason"); expect(lockManager.listAll()).toHaveLength(2); }); diff --git a/src/daemon/lock-manager.ts b/src/daemon/lock-manager.ts index 3865685..e59330d 100644 --- a/src/daemon/lock-manager.ts +++ b/src/daemon/lock-manager.ts @@ -19,22 +19,20 @@ export class LockManager { return auto || null; } - /** Auto-lock a directory for a session. Idempotent if same session. */ - autoLock(directory: string, sessionId: string): Lock { + /** Auto-lock a directory by PID. Idempotent if same PID+directory. */ + autoLock(directory: string, pid: number, sessionId?: string): Lock { const resolved = path.resolve(directory); const existing = this.state .getLocks() .find( - (l) => - l.directory === resolved && - l.type === "auto" && - l.sessionId === sessionId, + (l) => l.directory === resolved && l.type === "auto" && l.pid === pid, ); if (existing) return existing; const lock: Lock = { directory: resolved, type: "auto", + pid, sessionId, lockedAt: new Date().toISOString(), }; @@ -42,7 +40,12 @@ export class LockManager { return lock; } - /** Remove auto-lock for a session. */ + /** Remove auto-locks for a PID. */ + autoUnlockByPid(pid: number): void { + this.state.removeLocks((l) => l.type === "auto" && l.pid === pid); + } + + /** Remove auto-locks for a session ID (backward compat — prefers PID). */ autoUnlock(sessionId: string): void { this.state.removeLocks( (l) => l.type === "auto" && l.sessionId === sessionId, @@ -81,15 +84,27 @@ export class LockManager { ); } - /** Update the sessionId on auto-locks when a session ID is resolved to a different UUID. */ - updateAutoLockSessionId(oldId: string, newId: string): void { + /** + * Self-healing PID liveness cleanup. + * Checks all auto-locks: if the locking PID is dead, release the lock. + * Returns the PIDs that were cleaned up. + */ + cleanupDeadLocks(isProcessAlive: (pid: number) => boolean): number[] { + const deadPids: number[] = []; const locks = this.state.getLocks(); for (const lock of locks) { - if (lock.type === "auto" && lock.sessionId === oldId) { - lock.sessionId = newId; + if (lock.type !== "auto" || !lock.pid) continue; + if (!isProcessAlive(lock.pid)) { + deadPids.push(lock.pid); } } - this.state.markDirty(); + if (deadPids.length > 0) { + const deadSet = new Set(deadPids); + this.state.removeLocks( + (l) => l.type === "auto" && l.pid != null && deadSet.has(l.pid), + ); + } + return [...new Set(deadPids)]; } listAll(): Lock[] { diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 95779ad..9aa2b4b 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -131,12 +131,17 @@ export async function startDaemon(opts: DaemonStartOpts = {}): Promise<{ emitWebhook(webhookConfig, payload); }; - // 8. Initial PID liveness cleanup for daemon-launched sessions - // (replaces the old validateAllSessions — much simpler, only checks launches) + // 8. Initial PID liveness cleanup — self-healing locks + dead session detection + // Locks clean themselves via PID liveness (independent of session state). + const deadLockPids = lockManager.cleanupDeadLocks(isProcessAlive); + if (deadLockPids.length > 0) { + console.error( + `Startup cleanup: released ${deadLockPids.length} dead auto-lock(s)`, + ); + } const initialDead = sessionTracker.cleanupDeadLaunches(); if (initialDead.length > 0) { for (const id of initialDead) { - lockManager.autoUnlock(id); const rec = state.getSession(id); if (rec) emitSessionStoppedWebhook(rec); } @@ -201,9 +206,12 @@ export async function startDaemon(opts: DaemonStartOpts = {}): Promise<{ // 9. Resume fuse timers fuseEngine.resumeTimers(); - // 10. Start periodic PID liveness check for lock cleanup (30s interval) + // 10. Start periodic PID liveness checks (30s interval) + // Locks self-heal via PID liveness; session tracker handles session state. + const lockCleanupHandle = setInterval(() => { + lockManager.cleanupDeadLocks(isProcessAlive); + }, 30_000); sessionTracker.startLaunchCleanup((deadId) => { - lockManager.autoUnlock(deadId); const rec = state.getSession(deadId); if (rec) emitSessionStoppedWebhook(rec); }); @@ -285,6 +293,7 @@ export async function startDaemon(opts: DaemonStartOpts = {}): Promise<{ // Shutdown function const shutdown = async () => { + clearInterval(lockCleanupHandle); sessionTracker.stopLaunchCleanup(); fuseEngine.shutdown(); state.flush(); @@ -460,9 +469,9 @@ function createRequestHandler(ctx: HandlerContext) { const { sessions: allSessions, stoppedLaunchIds } = ctx.sessionTracker.reconcileAndEnrich(discovered, succeededAdapters); - // Release locks and emit webhooks for sessions that disappeared + // Emit webhooks for sessions that disappeared + // (Locks are self-healing via PID liveness — no need to couple to session state) for (const id of stoppedLaunchIds) { - ctx.lockManager.autoUnlock(id); const rec = ctx.state.getSession(id); if (rec) ctx.emitSessionStoppedWebhook(rec); } @@ -670,9 +679,9 @@ function createRequestHandler(ctx: HandlerContext) { const record = ctx.sessionTracker.track(session, adapterName); - // Auto-lock - if (cwd) { - ctx.lockManager.autoLock(cwd, session.id); + // Auto-lock by PID (self-healing — lock released when PID dies) + if (cwd && session.pid) { + ctx.lockManager.autoLock(cwd, session.pid, session.id); } return record; @@ -696,8 +705,12 @@ function createRequestHandler(ctx: HandlerContext) { force: params.force as boolean | undefined, }); - // Remove auto-lock - ctx.lockManager.autoUnlock(sessionId); + // Remove auto-lock (prefer PID-based, fallback to sessionId) + if (launchRecord?.pid) { + ctx.lockManager.autoUnlockByPid(launchRecord.pid); + } else { + ctx.lockManager.autoUnlock(sessionId); + } // Mark stopped in launch metadata const stopped = ctx.sessionTracker.onSessionExit(sessionId); @@ -725,13 +738,10 @@ function createRequestHandler(ctx: HandlerContext) { // --- Prune command (#40) --- kept for CLI backward compat case "session.prune": { - // In the stateless model, there's no session registry to prune. - // Clean up dead launches (PID liveness check) as a best-effort action. + // Clean up dead locks (PID liveness) + dead session records + const deadPids = ctx.lockManager.cleanupDeadLocks(isProcessAlive); const deadIds = ctx.sessionTracker.cleanupDeadLaunches(); - for (const id of deadIds) { - ctx.lockManager.autoUnlock(id); - } - return { pruned: deadIds.length }; + return { pruned: deadIds.length + deadPids.length }; } case "lock.list": diff --git a/src/daemon/state.ts b/src/daemon/state.ts index 31e46d4..ebab249 100644 --- a/src/daemon/state.ts +++ b/src/daemon/state.ts @@ -24,7 +24,8 @@ export interface SessionRecord { export interface Lock { directory: string; // absolute, resolved path type: "auto" | "manual"; - sessionId?: string; // for auto-locks + pid?: number; // for auto-locks — PID that owns the lock (drives lifecycle) + sessionId?: string; // for auto-locks — informational only (display/enrichment) lockedBy?: string; // for manual locks reason?: string; lockedAt: string; // ISO 8601 diff --git a/src/utils/session-meta.test.ts b/src/utils/session-meta.test.ts new file mode 100644 index 0000000..79493fa --- /dev/null +++ b/src/utils/session-meta.test.ts @@ -0,0 +1,134 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + cleanupExpiredMeta, + deleteSessionMeta, + readSessionMeta, + writeSessionMeta, +} from "./session-meta.js"; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "agentctl-meta-test-")); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe("session-meta", () => { + describe("writeSessionMeta + readSessionMeta", () => { + it("writes and reads minimal metadata", async () => { + await writeSessionMeta(tmpDir, { sessionId: "s1", pid: 12345 }); + const meta = await readSessionMeta(tmpDir, "s1"); + expect(meta).toBeDefined(); + expect(meta?.sessionId).toBe("s1"); + expect(meta?.pid).toBe(12345); + expect(meta?.launchedAt).toBeDefined(); + }); + + it("returns null for non-existent session", async () => { + const meta = await readSessionMeta(tmpDir, "nonexistent"); + expect(meta).toBeNull(); + }); + + it("returns null for non-existent directory", async () => { + const meta = await readSessionMeta("/tmp/does-not-exist-meta", "s1"); + expect(meta).toBeNull(); + }); + }); + + describe("TTL expiry", () => { + it("returns null for expired metadata (>24h)", async () => { + // Write a meta file with a launchedAt over 24 hours ago + const metaPath = path.join(tmpDir, "old-session.json"); + const oldMeta = { + sessionId: "old-session", + pid: 99999, + launchedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(), + }; + await fs.writeFile(metaPath, JSON.stringify(oldMeta)); + + const meta = await readSessionMeta(tmpDir, "old-session"); + expect(meta).toBeNull(); + }); + + it("deletes expired file on read", async () => { + const metaPath = path.join(tmpDir, "old-session.json"); + const oldMeta = { + sessionId: "old-session", + pid: 99999, + launchedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(), + }; + await fs.writeFile(metaPath, JSON.stringify(oldMeta)); + + await readSessionMeta(tmpDir, "old-session"); + + // File should be cleaned up + await expect(fs.access(metaPath)).rejects.toThrow(); + }); + + it("returns recent metadata (within 24h)", async () => { + await writeSessionMeta(tmpDir, { sessionId: "fresh", pid: 11111 }); + const meta = await readSessionMeta(tmpDir, "fresh"); + expect(meta).toBeDefined(); + expect(meta?.sessionId).toBe("fresh"); + }); + }); + + describe("deleteSessionMeta", () => { + it("deletes a metadata file", async () => { + await writeSessionMeta(tmpDir, { sessionId: "s1", pid: 12345 }); + await deleteSessionMeta(tmpDir, "s1"); + const meta = await readSessionMeta(tmpDir, "s1"); + expect(meta).toBeNull(); + }); + + it("does not throw for non-existent file", async () => { + await expect( + deleteSessionMeta(tmpDir, "nonexistent"), + ).resolves.not.toThrow(); + }); + }); + + describe("cleanupExpiredMeta", () => { + it("removes expired files and keeps fresh ones", async () => { + // Write a fresh meta + await writeSessionMeta(tmpDir, { sessionId: "fresh", pid: 11111 }); + + // Write an expired meta + const oldPath = path.join(tmpDir, "old.json"); + await fs.writeFile( + oldPath, + JSON.stringify({ + sessionId: "old", + pid: 22222, + launchedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(), + }), + ); + + const cleaned = await cleanupExpiredMeta(tmpDir); + expect(cleaned).toBe(1); + + // Fresh file should still exist + const freshMeta = await readSessionMeta(tmpDir, "fresh"); + expect(freshMeta).toBeDefined(); + + // Old file should be gone + await expect(fs.access(oldPath)).rejects.toThrow(); + }); + + it("returns 0 for empty directory", async () => { + const cleaned = await cleanupExpiredMeta(tmpDir); + expect(cleaned).toBe(0); + }); + + it("returns 0 for non-existent directory", async () => { + const cleaned = await cleanupExpiredMeta("/tmp/does-not-exist-cleanup"); + expect(cleaned).toBe(0); + }); + }); +}); diff --git a/src/utils/session-meta.ts b/src/utils/session-meta.ts new file mode 100644 index 0000000..d4d54e9 --- /dev/null +++ b/src/utils/session-meta.ts @@ -0,0 +1,146 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +/** 24-hour TTL for session metadata files */ +const META_TTL_MS = 24 * 60 * 60 * 1000; + +/** + * Minimal metadata persisted by launch() so PID-based status checks survive + * wrapper exit. Only stores PID + process start time — no cwd/model/prompt + * (those belong in daemon state, not adapter shadow files). + */ +export interface LaunchedSessionMeta { + sessionId: string; + pid: number; + /** Process start time from `ps -p -o lstart=` for PID recycling detection */ + startTime?: string; + launchedAt: string; // ISO 8601 — used for TTL expiry + /** Path to adapter launch log — used as fallback for peek on short-lived sessions */ + logPath?: string; +} + +/** + * Write minimal session metadata to disk. + * Captures process start time via `ps` for PID recycling detection. + */ +export async function writeSessionMeta( + metaDir: string, + meta: { sessionId: string; pid: number }, +): Promise { + await fs.mkdir(metaDir, { recursive: true }); + + let startTime: string | undefined; + try { + const { stdout } = await execFileAsync("ps", [ + "-p", + meta.pid.toString(), + "-o", + "lstart=", + ]); + startTime = stdout.trim() || undefined; + } catch { + // Process may have already exited or ps failed + } + + const fullMeta: LaunchedSessionMeta = { + sessionId: meta.sessionId, + pid: meta.pid, + startTime, + launchedAt: new Date().toISOString(), + }; + const metaPath = path.join(metaDir, `${meta.sessionId}.json`); + await fs.writeFile(metaPath, JSON.stringify(fullMeta, null, 2)); +} + +/** + * Read session metadata, returning null if not found or expired (24h TTL). + */ +export async function readSessionMeta( + metaDir: string, + sessionId: string, +): Promise { + // Exact match first + const metaPath = path.join(metaDir, `${sessionId}.json`); + try { + const raw = await fs.readFile(metaPath, "utf-8"); + const meta = JSON.parse(raw) as LaunchedSessionMeta; + if (isMetaExpired(meta)) { + await fs.unlink(metaPath).catch(() => {}); + return null; + } + return meta; + } catch { + // File doesn't exist or is unreadable + } + + // Scan for prefix match + try { + const files = await fs.readdir(metaDir); + for (const file of files) { + if (!file.endsWith(".json")) continue; + try { + const filePath = path.join(metaDir, file); + const raw = await fs.readFile(filePath, "utf-8"); + const meta = JSON.parse(raw) as LaunchedSessionMeta; + if (isMetaExpired(meta)) { + await fs.unlink(filePath).catch(() => {}); + continue; + } + if (meta.sessionId === sessionId) return meta; + } catch { + // skip + } + } + } catch { + // Dir doesn't exist + } + return null; +} + +/** + * Delete session metadata file. + */ +export async function deleteSessionMeta( + metaDir: string, + sessionId: string, +): Promise { + const metaPath = path.join(metaDir, `${sessionId}.json`); + await fs.unlink(metaPath).catch(() => {}); +} + +/** + * Clean up all expired metadata files (24h TTL). + * Safe to call periodically (e.g. during discover()). + */ +export async function cleanupExpiredMeta(metaDir: string): Promise { + let cleaned = 0; + try { + const files = await fs.readdir(metaDir); + for (const file of files) { + if (!file.endsWith(".json")) continue; + try { + const filePath = path.join(metaDir, file); + const raw = await fs.readFile(filePath, "utf-8"); + const meta = JSON.parse(raw) as LaunchedSessionMeta; + if (isMetaExpired(meta)) { + await fs.unlink(filePath).catch(() => {}); + cleaned++; + } + } catch { + // skip unreadable files + } + } + } catch { + // Dir doesn't exist — nothing to clean + } + return cleaned; +} + +function isMetaExpired(meta: LaunchedSessionMeta): boolean { + if (!meta.launchedAt) return false; + return Date.now() - new Date(meta.launchedAt).getTime() > META_TTL_MS; +}