/ services / autoDream / consolidationLock.ts
consolidationLock.ts
  1  // Lock file whose mtime IS lastConsolidatedAt. Body is the holder's PID.
  2  //
  3  // Lives inside the memory dir (getAutoMemPath) so it keys on git-root
  4  // like memory does, and so it's writable even when the memory path comes
  5  // from an env/settings override whose parent may not be.
  6  
  7  import { mkdir, readFile, stat, unlink, utimes, writeFile } from 'fs/promises'
  8  import { join } from 'path'
  9  import { getOriginalCwd } from '../../bootstrap/state.js'
 10  import { getAutoMemPath } from '../../memdir/paths.js'
 11  import { logForDebugging } from '../../utils/debug.js'
 12  import { isProcessRunning } from '../../utils/genericProcessUtils.js'
 13  import { listCandidates } from '../../utils/listSessionsImpl.js'
 14  import { getProjectDir } from '../../utils/sessionStorage.js'
 15  
 16  const LOCK_FILE = '.consolidate-lock'
 17  
 18  // Stale past this even if the PID is live (PID reuse guard).
 19  const HOLDER_STALE_MS = 60 * 60 * 1000
 20  
 21  function lockPath(): string {
 22    return join(getAutoMemPath(), LOCK_FILE)
 23  }
 24  
 25  /**
 26   * mtime of the lock file = lastConsolidatedAt. 0 if absent.
 27   * Per-turn cost: one stat.
 28   */
 29  export async function readLastConsolidatedAt(): Promise<number> {
 30    try {
 31      const s = await stat(lockPath())
 32      return s.mtimeMs
 33    } catch {
 34      return 0
 35    }
 36  }
 37  
 38  /**
 39   * Acquire: write PID → mtime = now. Returns the pre-acquire mtime
 40   * (for rollback), or null if blocked / lost a race.
 41   *
 42   *   Success → do nothing. mtime stays at now.
 43   *   Failure → rollbackConsolidationLock(priorMtime) rewinds mtime.
 44   *   Crash   → mtime stuck, dead PID → next process reclaims.
 45   */
 46  export async function tryAcquireConsolidationLock(): Promise<number | null> {
 47    const path = lockPath()
 48  
 49    let mtimeMs: number | undefined
 50    let holderPid: number | undefined
 51    try {
 52      const [s, raw] = await Promise.all([stat(path), readFile(path, 'utf8')])
 53      mtimeMs = s.mtimeMs
 54      const parsed = parseInt(raw.trim(), 10)
 55      holderPid = Number.isFinite(parsed) ? parsed : undefined
 56    } catch {
 57      // ENOENT — no prior lock.
 58    }
 59  
 60    if (mtimeMs !== undefined && Date.now() - mtimeMs < HOLDER_STALE_MS) {
 61      if (holderPid !== undefined && isProcessRunning(holderPid)) {
 62        logForDebugging(
 63          `[autoDream] lock held by live PID ${holderPid} (mtime ${Math.round((Date.now() - mtimeMs) / 1000)}s ago)`,
 64        )
 65        return null
 66      }
 67      // Dead PID or unparseable body — reclaim.
 68    }
 69  
 70    // Memory dir may not exist yet.
 71    await mkdir(getAutoMemPath(), { recursive: true })
 72    await writeFile(path, String(process.pid))
 73  
 74    // Two reclaimers both write → last wins the PID. Loser bails on re-read.
 75    let verify: string
 76    try {
 77      verify = await readFile(path, 'utf8')
 78    } catch {
 79      return null
 80    }
 81    if (parseInt(verify.trim(), 10) !== process.pid) return null
 82  
 83    return mtimeMs ?? 0
 84  }
 85  
 86  /**
 87   * Rewind mtime to pre-acquire after a failed fork. Clears the PID body —
 88   * otherwise our still-running process would look like it's holding.
 89   * priorMtime 0 → unlink (restore no-file).
 90   */
 91  export async function rollbackConsolidationLock(
 92    priorMtime: number,
 93  ): Promise<void> {
 94    const path = lockPath()
 95    try {
 96      if (priorMtime === 0) {
 97        await unlink(path)
 98        return
 99      }
100      await writeFile(path, '')
101      const t = priorMtime / 1000 // utimes wants seconds
102      await utimes(path, t, t)
103    } catch (e: unknown) {
104      logForDebugging(
105        `[autoDream] rollback failed: ${(e as Error).message} — next trigger delayed to minHours`,
106      )
107    }
108  }
109  
110  /**
111   * Session IDs with mtime after sinceMs. listCandidates handles UUID
112   * validation (excludes agent-*.jsonl) and parallel stat.
113   *
114   * Uses mtime (sessions TOUCHED since), not birthtime (0 on ext4).
115   * Caller excludes the current session. Scans per-cwd transcripts — it's
116   * a skip-gate, so undercounting worktree sessions is safe.
117   */
118  export async function listSessionsTouchedSince(
119    sinceMs: number,
120  ): Promise<string[]> {
121    const dir = getProjectDir(getOriginalCwd())
122    const candidates = await listCandidates(dir, true)
123    return candidates.filter(c => c.mtime > sinceMs).map(c => c.sessionId)
124  }
125  
126  /**
127   * Stamp from manual /dream. Optimistic — fires at prompt-build time,
128   * no post-skill completion hook. Best-effort.
129   */
130  export async function recordConsolidation(): Promise<void> {
131    try {
132      // Memory dir may not exist yet (manual /dream before any auto-trigger).
133      await mkdir(getAutoMemPath(), { recursive: true })
134      await writeFile(lockPath(), String(process.pid))
135    } catch (e: unknown) {
136      logForDebugging(
137        `[autoDream] recordConsolidation write failed: ${(e as Error).message}`,
138      )
139    }
140  }