diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f5430793..a38a6a41 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ "name": "claudeclaw", "source": "./", "description": "Cron-like daemon that runs Claude prompts on a schedule", - "version": "1.0.40", + "version": "1.0.41", "keywords": [ "cron", "heartbeat", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 2688e3ab..e2be5931 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "claudeclaw", - "version": "1.0.40", + "version": "1.0.41", "description": "Cron-like daemon that runs Claude prompts on a schedule" } diff --git a/src/runner.ts b/src/runner.ts index 7d0ee6a1..171642c9 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -1050,7 +1050,7 @@ async function execClaude( const existing = threadId ? await getThreadSession(threadId) : await getSession(agentName); - const isNew = !existing; + const isNew = !existing?.sessionId; // Start the watchdog clock for resumed sessions (we know the ID immediately). if (existing) startSession(existing.sessionId); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); diff --git a/src/sessionManager.ts b/src/sessionManager.ts index 177634c4..89a6a90d 100644 --- a/src/sessionManager.ts +++ b/src/sessionManager.ts @@ -1,4 +1,5 @@ import { join } from "path"; +import { hasValidSessionId } from "./sessionValidate"; const HEARTBEAT_DIR = join(process.cwd(), ".claude", "claudeclaw"); const SESSIONS_FILE = join(HEARTBEAT_DIR, "sessions.json"); @@ -40,7 +41,7 @@ export async function getThreadSession( ): Promise<{ sessionId: string; turnCount: number; compactWarned: boolean } | null> { const data = await loadSessions(); const session = data.threads[threadId]; - if (!session) return null; + if (!hasValidSessionId(session)) return null; if (typeof session.turnCount !== "number") session.turnCount = 0; if (typeof session.compactWarned !== "boolean") session.compactWarned = false; @@ -97,14 +98,15 @@ export async function markThreadCompactWarned(threadId: string): Promise { await saveSessions(data); } -/** List all active thread sessions. */ +/** List all active thread sessions. Drops corrupted rows missing a sessionId. */ export async function listThreadSessions(): Promise { const data = await loadSessions(); - return Object.values(data.threads); + return Object.values(data.threads).filter(hasValidSessionId); } /** Peek at a thread session without updating lastUsedAt. */ export async function peekThreadSession(threadId: string): Promise { const data = await loadSessions(); - return data.threads[threadId] ?? null; + const session = data.threads[threadId]; + return hasValidSessionId(session) ? session : null; } diff --git a/src/sessionValidate.ts b/src/sessionValidate.ts new file mode 100644 index 00000000..7d4f860e --- /dev/null +++ b/src/sessionValidate.ts @@ -0,0 +1,6 @@ +/** Parsed session records must carry a non-empty sessionId or be treated as absent. */ +export function hasValidSessionId(value: unknown): value is { sessionId: string } { + if (!value || typeof value !== "object") return false; + const sessionId = (value as { sessionId?: unknown }).sessionId; + return typeof sessionId === "string" && sessionId.length > 0; +} diff --git a/src/sessions.ts b/src/sessions.ts index 9df78436..c54fdf3d 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -1,6 +1,7 @@ import { dirname, join } from "path"; import { unlink, readdir, rename, mkdir } from "fs/promises"; import { getAgentsDir } from "./config"; +import { hasValidSessionId } from "./sessionValidate"; const HEARTBEAT_DIR = join(process.cwd(), ".claude", "claudeclaw"); const SESSION_FILE = join(HEARTBEAT_DIR, "session.json"); @@ -14,6 +15,8 @@ export interface GlobalSession { messageCount?: number; } +export { hasValidSessionId } from "./sessionValidate"; + // Module-level cache is for the GLOBAL session only. // Agent sessions bypass this cache — they read/write directly. let current: GlobalSession | null = null; @@ -26,16 +29,26 @@ function sessionPathFor(agentName?: string): string { async function loadSession(agentName?: string): Promise { if (agentName) { try { - return await Bun.file(sessionPathFor(agentName)).json(); + const session = await Bun.file(sessionPathFor(agentName)).json(); + return hasValidSessionId(session) ? session : null; } catch { return null; } } - if (current) return current; + if (current) { + if (hasValidSessionId(current)) return current; + current = null; + } try { - current = await Bun.file(SESSION_FILE).json(); + const session = await Bun.file(SESSION_FILE).json(); + if (!hasValidSessionId(session)) { + current = null; + return null; + } + current = session; return current; } catch { + current = null; return null; } } @@ -126,7 +139,8 @@ function fallbackSessionPathFor(agentName?: string, threadId?: string): string { async function loadFallbackSession(agentName?: string, threadId?: string): Promise { try { - return await Bun.file(fallbackSessionPathFor(agentName, threadId)).json(); + const session = await Bun.file(fallbackSessionPathFor(agentName, threadId)).json(); + return hasValidSessionId(session) ? session : null; } catch { return null; } diff --git a/tests/sessions-missing-id.test.ts b/tests/sessions-missing-id.test.ts new file mode 100644 index 00000000..befc09b3 --- /dev/null +++ b/tests/sessions-missing-id.test.ts @@ -0,0 +1,46 @@ +import { readFileSync } from "node:fs"; +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { hasValidSessionId } from "../src/sessionValidate.ts"; + +describe("hasValidSessionId", () => { + it("accepts sessions with a non-empty sessionId", () => { + assert.equal( + hasValidSessionId({ + sessionId: "11111111-1111-4111-8111-111111111111", + createdAt: "2026-01-01T00:00:00.000Z", + lastUsedAt: "2026-01-01T00:00:00.000Z", + turnCount: 0, + compactWarned: false, + }), + true, + ); + }); + + it("rejects parseable JSON missing sessionId (issue #228)", () => { + assert.equal( + hasValidSessionId({ + turnCount: 0, + compactWarned: false, + lastUsedAt: "2026-01-01T00:00:00.000Z", + }), + false, + ); + assert.equal(hasValidSessionId({ sessionId: "" }), false); + assert.equal(hasValidSessionId(null), false); + }); +}); + +describe("runner treats missing sessionId as new session", () => { + it("uses optional sessionId when computing isNew", () => { + const src = readFileSync(new URL("../src/runner.ts", import.meta.url), "utf8"); + assert.match(src, /const isNew = !existing\?\.sessionId;/); + }); +}); + +describe("thread session peeking", () => { + it("validates sessions.json rows before returning them", () => { + const src = readFileSync(new URL("../src/sessionManager.ts", import.meta.url), "utf8"); + assert.match(src, /return hasValidSessionId\(session\) \? session : null;/); + }); +});