import { Data, Effect } from "effect"
import { open, rm, stat } from "node:fs/promises"
import { join } from "node:path"
class WorktreeLockError extends Data.TaggedError("WorktreeLockError")<{
readonly message: string
readonly cause?: unknown
}> {}
export interface WorktreeLockTarget {
readonly taskId: string
readonly phaseId?: string
}
export interface WorktreeLockOptions {
readonly force?: boolean
}
const lockTimeoutMs = 10 * 60 * 1000
const isErrorCode = (cause: unknown, code: string) =>
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === code
const withWorktreeLock = (
root: string,
target: WorktreeLockTarget,
effect: Effect.Effect,
options: WorktreeLockOptions,
): Effect.Effect => {
const key = Buffer.from(
`${target.taskId}:${target.phaseId ?? "task"}`,
).toString("hex")
const lockPath = join(root, `.agency-worktree-${key}.lock`)
const removalCommand = `rm '${lockPath.replaceAll("'", `'\\''`)}'`
return Effect.acquireUseRelease(
Effect.tryPromise({
try: async () => {
try {
return await open(lockPath, "wx")
} catch (cause) {
if (!isErrorCode(cause, "EEXIST")) throw cause
let stale = false
try {
stale = Date.now() - (await stat(lockPath)).mtimeMs >= lockTimeoutMs
} catch (statCause) {
if (!isErrorCode(statCause, "ENOENT")) throw statCause
}
if (!options.force && !stale) throw cause
await rm(lockPath, { force: true })
return open(lockPath, "wx")
}
},
catch: (cause) =>
new WorktreeLockError({
message: `Another worktree operation is in progress for '${target.taskId}${target.phaseId ? `/${target.phaseId}` : ""}'. Retry with --force or remove the stale sentinel with: ${removalCommand}`,
cause,
}),
}),
() => effect,
(lock) =>
Effect.promise(async () => {
let ownsLock = false
try {
const [held, current] = await Promise.all([
lock.stat(),
stat(lockPath),
])
ownsLock = held.dev === current.dev && held.ino === current.ino
} catch {}
await lock.close().catch(() => undefined)
if (ownsLock) await rm(lockPath, { force: true }).catch(() => undefined)
}),
)
}
export const withWorktreeLocks = (
root: string,
targets: readonly WorktreeLockTarget[],
effect: Effect.Effect,
options: WorktreeLockOptions = {},
): Effect.Effect => {
const unique = new Map(
targets.map((target) => [
`${target.taskId}:${target.phaseId ?? "task"}`,
target,
]),
)
let current: Effect.Effect = effect
for (const [, target] of [...unique.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.reverse()) {
current = withWorktreeLock(root, target, current, options)
}
return current
}