141 lines
4.4 KiB
TypeScript
141 lines
4.4 KiB
TypeScript
// Lock file whose mtime IS lastConsolidatedAt. Body is the holder's PID.
|
|
//
|
|
// Lives inside the memory dir (getAutoMemPath) so it keys on git-root
|
|
// like memory does, and so it's writable even when the memory path comes
|
|
// from an env/settings override whose parent may not be.
|
|
|
|
import { mkdir, readFile, stat, unlink, utimes, writeFile } from 'fs/promises'
|
|
import { join } from 'path'
|
|
import { getOriginalCwd } from '../../bootstrap/state.js'
|
|
import { getAutoMemPath } from '../../memdir/paths.js'
|
|
import { logForDebugging } from '../../utils/debug.js'
|
|
import { isProcessRunning } from '../../utils/genericProcessUtils.js'
|
|
import { listCandidates } from '../../utils/listSessionsImpl.js'
|
|
import { getProjectDir } from '../../utils/sessionStorage.js'
|
|
|
|
const LOCK_FILE = '.consolidate-lock'
|
|
|
|
// Stale past this even if the PID is live (PID reuse guard).
|
|
const HOLDER_STALE_MS = 60 * 60 * 1000
|
|
|
|
function lockPath(): string {
|
|
return join(getAutoMemPath(), LOCK_FILE)
|
|
}
|
|
|
|
/**
|
|
* mtime of the lock file = lastConsolidatedAt. 0 if absent.
|
|
* Per-turn cost: one stat.
|
|
*/
|
|
export async function readLastConsolidatedAt(): Promise<number> {
|
|
try {
|
|
const s = await stat(lockPath())
|
|
return s.mtimeMs
|
|
} catch {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Acquire: write PID → mtime = now. Returns the pre-acquire mtime
|
|
* (for rollback), or null if blocked / lost a race.
|
|
*
|
|
* Success → do nothing. mtime stays at now.
|
|
* Failure → rollbackConsolidationLock(priorMtime) rewinds mtime.
|
|
* Crash → mtime stuck, dead PID → next process reclaims.
|
|
*/
|
|
export async function tryAcquireConsolidationLock(): Promise<number | null> {
|
|
const path = lockPath()
|
|
|
|
let mtimeMs: number | undefined
|
|
let holderPid: number | undefined
|
|
try {
|
|
const [s, raw] = await Promise.all([stat(path), readFile(path, 'utf8')])
|
|
mtimeMs = s.mtimeMs
|
|
const parsed = parseInt(raw.trim(), 10)
|
|
holderPid = Number.isFinite(parsed) ? parsed : undefined
|
|
} catch {
|
|
// ENOENT — no prior lock.
|
|
}
|
|
|
|
if (mtimeMs !== undefined && Date.now() - mtimeMs < HOLDER_STALE_MS) {
|
|
if (holderPid !== undefined && isProcessRunning(holderPid)) {
|
|
logForDebugging(
|
|
`[autoDream] lock held by live PID ${holderPid} (mtime ${Math.round((Date.now() - mtimeMs) / 1000)}s ago)`,
|
|
)
|
|
return null
|
|
}
|
|
// Dead PID or unparseable body — reclaim.
|
|
}
|
|
|
|
// Memory dir may not exist yet.
|
|
await mkdir(getAutoMemPath(), { recursive: true })
|
|
await writeFile(path, String(process.pid))
|
|
|
|
// Two reclaimers both write → last wins the PID. Loser bails on re-read.
|
|
let verify: string
|
|
try {
|
|
verify = await readFile(path, 'utf8')
|
|
} catch {
|
|
return null
|
|
}
|
|
if (parseInt(verify.trim(), 10) !== process.pid) return null
|
|
|
|
return mtimeMs ?? 0
|
|
}
|
|
|
|
/**
|
|
* Rewind mtime to pre-acquire after a failed fork. Clears the PID body —
|
|
* otherwise our still-running process would look like it's holding.
|
|
* priorMtime 0 → unlink (restore no-file).
|
|
*/
|
|
export async function rollbackConsolidationLock(
|
|
priorMtime: number,
|
|
): Promise<void> {
|
|
const path = lockPath()
|
|
try {
|
|
if (priorMtime === 0) {
|
|
await unlink(path)
|
|
return
|
|
}
|
|
await writeFile(path, '')
|
|
const t = priorMtime / 1000 // utimes wants seconds
|
|
await utimes(path, t, t)
|
|
} catch (e: unknown) {
|
|
logForDebugging(
|
|
`[autoDream] rollback failed: ${(e as Error).message} — next trigger delayed to minHours`,
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Session IDs with mtime after sinceMs. listCandidates handles UUID
|
|
* validation (excludes agent-*.jsonl) and parallel stat.
|
|
*
|
|
* Uses mtime (sessions TOUCHED since), not birthtime (0 on ext4).
|
|
* Caller excludes the current session. Scans per-cwd transcripts — it's
|
|
* a skip-gate, so undercounting worktree sessions is safe.
|
|
*/
|
|
export async function listSessionsTouchedSince(
|
|
sinceMs: number,
|
|
): Promise<string[]> {
|
|
const dir = getProjectDir(getOriginalCwd())
|
|
const candidates = await listCandidates(dir, true)
|
|
return candidates.filter(c => c.mtime > sinceMs).map(c => c.sessionId)
|
|
}
|
|
|
|
/**
|
|
* Stamp from manual /dream. Optimistic — fires at prompt-build time,
|
|
* no post-skill completion hook. Best-effort.
|
|
*/
|
|
export async function recordConsolidation(): Promise<void> {
|
|
try {
|
|
// Memory dir may not exist yet (manual /dream before any auto-trigger).
|
|
await mkdir(getAutoMemPath(), { recursive: true })
|
|
await writeFile(lockPath(), String(process.pid))
|
|
} catch (e: unknown) {
|
|
logForDebugging(
|
|
`[autoDream] recordConsolidation write failed: ${(e as Error).message}`,
|
|
)
|
|
}
|
|
}
|