# lock; shared per-resource process lock

`@stravica-ai/engineering-pa-core` › `acquireLock`; the platform-wide primitive
for "at most one of me at a time". Public core API: consumed default-on by
`pa launch` for every scheduled-task run, and importable by any direct-run
tool (anything a cron invokes without going through `pa launch`) that needs
the same protection.

## Why it exists

Two runs of the same scheduled task on the same host must not overlap. The
motivating incident (2026-07-06): a supervised distillation sweep ran 1288+
seconds and was still running as the next cron tick approached; without a
lock, two Claude sweeps would have consumed the same staging areas
concurrently (duplicate routing, git races). The lock closes that class of
gap at the root: the second run exits cleanly with a logged skip instead of
spawning a second session.

## Import

```js
import { acquireLock, DEFAULT_STALE_AFTER_MS } from '@stravica-ai/engineering-pa-core';
```

## API

```js
const lock = await acquireLock(lockPath, {
  staleAfterMs,     // optional; see "Staleness policy"; default 2 h
  now,              // optional clock seam (tests)
  pid,              // optional owner pid (default process.pid)
  isProcessAlive,   // optional liveness seam (tests)
  fs,               // optional fs seam: { readFile, writeFile, unlink, stat, mkdir }
});
```

Returns one of:

```js
// Live contention; someone else legitimately holds it. Never thrown.
{ acquired: false,
  holder: { pid, acquiredAt },      // pid/acquiredAt null if lock unreadable
  reason: 'held by pid 1234 since 2026-07-06T11:40:00Z' }

// Acquired (possibly after breaking a stale lock).
{ acquired: true,
  record: { pid, acquiredAt },      // what was written to the lock file
  stale: null | { pid, acquiredAt, reason: 'dead-pid' | 'age-exceeded' | 'unreadable' },
  release: async () => {} }         // call on every exit path (try/finally)
```

When `stale` is non-null the caller SHOULD log a warning naming the broken
lock; the steal is safe but worth an audit trail.

## Lock file

One JSON file per protected resource, camelCase properties:

```json
{ "pid": 12345, "acquiredAt": "2026-07-06T12:00:00Z" }
```

- **Path is caller-chosen.** Convention: one path per resource under the
  operator repo's gitignored `.cache/` area (tooling standard §16;
  operational artefacts are never git-tracked). `pa launch` uses
  `.cache/launch/<taskId>.lock`.
- **First run needs no setup.** A missing lock file or missing parent
  directory is the normal empty state; the directory is created on demand.
- **Release is ownership-checked.** `release()` deletes the file only if it
  still carries this acquisition's pid + timestamp, so a long run whose lock
  was stolen never deletes the successor's lock. Release of an
  already-deleted lock is a no-op.

## Staleness policy

Two signals decide whether an existing lock is stale, in order:

1. **Dead pid; primary.** If the recorded process no longer exists
   (`process.kill(pid, 0)` semantics; `EPERM` counts as alive), the lock is
   broken immediately regardless of age. This is what recovers from crashed
   runs; a run killed mid-task cannot wedge the next tick.
2. **Age threshold; backstop.** If the recorded process appears alive but
   the lock is older than `staleAfterMs`, it is broken anyway. This covers
   pid reuse (the recorded pid now names an unrelated process, so signal 1
   would report "alive" forever) and unreadable lock files (crash mid-write:
   the pid cannot be checked, so file mtime vs `staleAfterMs` is the only
   signal; fresher than the threshold is treated as held, older is broken).

**Default: `DEFAULT_STALE_AFTER_MS` = 2 hours.** Rationale: the motivating
consumers are scheduled agent sweeps whose legitimate runtimes are routinely
tens of minutes (observed: 1288+ seconds), so a short window would steal a
live lock mid-sweep; 2 hours safely clears observed long runs while still
self-healing a pid-reuse wedge within one-to-two ticks of a typical 4-hourly
schedule. The threshold is deliberately caller-configurable:

- **Fast, well-bounded jobs should tighten it.** The `sync` CLI (seconds-long
  git runs on a 30-minute tick) passes 10 minutes.
- **Jobs that can legitimately run longer than 2 hours must raise it.**
- **`pa launch` consumers** set it per task via the optional
  `lockStaleAfterMinutes` field in `schedules/<id>.json`; no code needed.

## Contention semantics for cron consumers

`acquireLock` never throws for "someone else holds it"; it reports and
lets the caller choose exit semantics. The platform convention for scheduled
consumers is: log a greppable skip line naming the holder, write a
`noop-locked` audit record (tooling standard §15), and **exit 0**; an
overlap skip is a healthy no-op, not a failure, and must not generate cron
mail. `pa launch` and `sync` both follow this. A consumer that needs the
skip to be visible to a supervisor may exit 2 instead; document the choice.

## Adopting in a direct-run tool

```js
import { acquireLock } from '@stravica-ai/engineering-pa-core';

const lock = await acquireLock(join(repoRoot, '.cache', 'my-tool', 'my-tool.lock'));
if (!lock.acquired) {
  console.error(`[my-tool] skipped; ${lock.reason}`);
  process.exit(0); // healthy no-op; see contention semantics above
}
if (lock.stale) {
  console.error(`[my-tool] warning: broke stale lock (${lock.stale.reason}); proceeding`);
}
try {
  // ... the work ...
} finally {
  await lock.release();
}
```

## Consumers

| Consumer | Lock path | staleAfterMs |
|---|---|---|
| `pa launch` (default-on, per task) | `.cache/launch/<taskId>.lock` | 2 h default; per-task `lockStaleAfterMinutes` |
| `estate-sync` CLI (alias: `sync`) | `.cache/estate-sync/.lock` | 10 min |

## Tests

```
node --test packages/core/test/lock/lock.test.mjs
```

Covers: acquire on empty path, first-run mkdir against the real fs, live
contention, the 1288-second long-sweep no-steal case, dead-pid steal,
age-exceeded steal, caller-configured thresholds, unreadable-lock-file
handling, ownership-checked release, release-after-steal, release-idempotence.

## Standards

Follows the [tooling standard](./standards/tooling.md): Node stdlib only
(§3), dependency injection over mocking (§8), camelCase JSON (§11),
gitignored operational artefacts (§16). Exit-code guidance for consumers
derives from §6.
