File size: 4,548 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 | // 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}`,
)
}
}
|