init claude-code
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
// Scheduler lease lock for .claude/scheduled_tasks.json.
|
||||
//
|
||||
// When multiple Claude sessions run in the same project directory, only one
|
||||
// should drive the cron scheduler. The first session to acquire this lock
|
||||
// becomes the scheduler; others stay passive and periodically probe the lock.
|
||||
// If the owner dies (PID no longer running), a passive session takes over.
|
||||
//
|
||||
// Pattern mirrors computerUseLock.ts: O_EXCL atomic create, PID liveness
|
||||
// probe, stale-lock recovery, cleanup-on-exit.
|
||||
|
||||
import { mkdir, readFile, unlink, writeFile } from 'fs/promises'
|
||||
import { dirname, join } from 'path'
|
||||
import { z } from 'zod/v4'
|
||||
import { getProjectRoot, getSessionId } from '../bootstrap/state.js'
|
||||
import { registerCleanup } from './cleanupRegistry.js'
|
||||
import { logForDebugging } from './debug.js'
|
||||
import { getErrnoCode } from './errors.js'
|
||||
import { isProcessRunning } from './genericProcessUtils.js'
|
||||
import { safeParseJSON } from './json.js'
|
||||
import { lazySchema } from './lazySchema.js'
|
||||
import { jsonStringify } from './slowOperations.js'
|
||||
|
||||
const LOCK_FILE_REL = join('.claude', 'scheduled_tasks.lock')
|
||||
|
||||
const schedulerLockSchema = lazySchema(() =>
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
pid: z.number(),
|
||||
acquiredAt: z.number(),
|
||||
}),
|
||||
)
|
||||
type SchedulerLock = z.infer<ReturnType<typeof schedulerLockSchema>>
|
||||
|
||||
/**
|
||||
* Options for out-of-REPL callers (Agent SDK daemon) that don't have
|
||||
* bootstrap state. When omitted, falls back to getProjectRoot() +
|
||||
* getSessionId() as before. lockIdentity should be stable for the lifetime
|
||||
* of one daemon process (e.g. a randomUUID() captured at startup).
|
||||
*/
|
||||
export type SchedulerLockOptions = {
|
||||
dir?: string
|
||||
lockIdentity?: string
|
||||
}
|
||||
|
||||
let unregisterCleanup: (() => void) | undefined
|
||||
// Suppress repeat "held by X" log lines when polling a live owner.
|
||||
let lastBlockedBy: string | undefined
|
||||
|
||||
function getLockPath(dir?: string): string {
|
||||
return join(dir ?? getProjectRoot(), LOCK_FILE_REL)
|
||||
}
|
||||
|
||||
async function readLock(dir?: string): Promise<SchedulerLock | undefined> {
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(getLockPath(dir), 'utf8')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
const result = schedulerLockSchema().safeParse(safeParseJSON(raw, false))
|
||||
return result.success ? result.data : undefined
|
||||
}
|
||||
|
||||
async function tryCreateExclusive(
|
||||
lock: SchedulerLock,
|
||||
dir?: string,
|
||||
): Promise<boolean> {
|
||||
const path = getLockPath(dir)
|
||||
const body = jsonStringify(lock)
|
||||
try {
|
||||
await writeFile(path, body, { flag: 'wx' })
|
||||
return true
|
||||
} catch (e: unknown) {
|
||||
const code = getErrnoCode(e)
|
||||
if (code === 'EEXIST') return false
|
||||
if (code === 'ENOENT') {
|
||||
// .claude/ doesn't exist yet — create it and retry once. In steady
|
||||
// state the dir already exists (scheduled_tasks.json lives there),
|
||||
// so this path is hit at most once.
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
try {
|
||||
await writeFile(path, body, { flag: 'wx' })
|
||||
return true
|
||||
} catch (retryErr: unknown) {
|
||||
if (getErrnoCode(retryErr) === 'EEXIST') return false
|
||||
throw retryErr
|
||||
}
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function registerLockCleanup(opts?: SchedulerLockOptions): void {
|
||||
unregisterCleanup?.()
|
||||
unregisterCleanup = registerCleanup(async () => {
|
||||
await releaseSchedulerLock(opts)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to acquire the scheduler lock for the current session.
|
||||
* Returns true on success, false if another live session holds it.
|
||||
*
|
||||
* Uses O_EXCL ('wx') for atomic test-and-set. If the file exists:
|
||||
* - Already ours → true (idempotent re-acquire)
|
||||
* - Another live PID → false
|
||||
* - Stale (PID dead / corrupt) → unlink and retry exclusive create once
|
||||
*
|
||||
* If two sessions race to recover a stale lock, only one create succeeds.
|
||||
*/
|
||||
export async function tryAcquireSchedulerLock(
|
||||
opts?: SchedulerLockOptions,
|
||||
): Promise<boolean> {
|
||||
const dir = opts?.dir
|
||||
// "sessionId" in the lock file is really just a stable owner key. REPL
|
||||
// uses getSessionId(); daemon callers supply their own UUID. PID remains
|
||||
// the liveness signal regardless.
|
||||
const sessionId = opts?.lockIdentity ?? getSessionId()
|
||||
const lock: SchedulerLock = {
|
||||
sessionId,
|
||||
pid: process.pid,
|
||||
acquiredAt: Date.now(),
|
||||
}
|
||||
|
||||
if (await tryCreateExclusive(lock, dir)) {
|
||||
lastBlockedBy = undefined
|
||||
registerLockCleanup(opts)
|
||||
logForDebugging(
|
||||
`[ScheduledTasks] acquired scheduler lock (PID ${process.pid})`,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
const existing = await readLock(dir)
|
||||
|
||||
// Already ours (idempotent). After --resume the session ID is restored
|
||||
// but the process has a new PID — update the lock file so other sessions
|
||||
// see a live PID and don't steal it.
|
||||
if (existing?.sessionId === sessionId) {
|
||||
if (existing.pid !== process.pid) {
|
||||
await writeFile(getLockPath(dir), jsonStringify(lock))
|
||||
registerLockCleanup(opts)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Corrupt or unparseable — treat as stale.
|
||||
// Another live session — blocked.
|
||||
if (existing && isProcessRunning(existing.pid)) {
|
||||
if (lastBlockedBy !== existing.sessionId) {
|
||||
lastBlockedBy = existing.sessionId
|
||||
logForDebugging(
|
||||
`[ScheduledTasks] scheduler lock held by session ${existing.sessionId} (PID ${existing.pid})`,
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Stale — unlink and retry the exclusive create once.
|
||||
if (existing) {
|
||||
logForDebugging(
|
||||
`[ScheduledTasks] recovering stale scheduler lock from PID ${existing.pid}`,
|
||||
)
|
||||
}
|
||||
await unlink(getLockPath(dir)).catch(() => {})
|
||||
if (await tryCreateExclusive(lock, dir)) {
|
||||
lastBlockedBy = undefined
|
||||
registerLockCleanup(opts)
|
||||
return true
|
||||
}
|
||||
// Another session won the recovery race.
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the scheduler lock if the current session owns it.
|
||||
*/
|
||||
export async function releaseSchedulerLock(
|
||||
opts?: SchedulerLockOptions,
|
||||
): Promise<void> {
|
||||
unregisterCleanup?.()
|
||||
unregisterCleanup = undefined
|
||||
lastBlockedBy = undefined
|
||||
|
||||
const dir = opts?.dir
|
||||
const sessionId = opts?.lockIdentity ?? getSessionId()
|
||||
const existing = await readLock(dir)
|
||||
if (!existing || existing.sessionId !== sessionId) return
|
||||
try {
|
||||
await unlink(getLockPath(dir))
|
||||
logForDebugging('[ScheduledTasks] released scheduler lock')
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user