| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195 |
- // 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.
- }
- }
|