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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 10 additions & 12 deletions src/adapters/claude-code.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", [
{
Expand All @@ -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(
Expand Down Expand Up @@ -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"),
Expand All @@ -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", [
{
Expand All @@ -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"),
Expand Down Expand Up @@ -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", [
{
Expand All @@ -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(
Expand Down
128 changes: 21 additions & 107 deletions src/adapters/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 <pid> -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
Expand Down Expand Up @@ -147,6 +141,9 @@ export class ClaudeCodeAdapter implements AgentAdapter {
}

async discover(): Promise<DiscoveredSession[]> {
// 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;
Expand Down Expand Up @@ -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();
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)) {
Expand All @@ -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;
}
}
Expand All @@ -982,15 +970,15 @@ 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
// (only for sessions launched with the new detached model)
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)
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<LaunchedSessionMeta, "startTime">,
): Promise<void> {
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<LaunchedSessionMeta | null> {
// 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<void> {
{
const metaPath = path.join(this.sessionsMetaDir, `${sessionId}.json`);
try {
await fs.unlink(metaPath);
} catch {
// File doesn't exist
}
}
}
}

// --- Utility functions ---
Expand Down
21 changes: 9 additions & 12 deletions src/adapters/codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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"),
Expand All @@ -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(
Expand All @@ -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");
});
});
Expand Down
Loading
Loading