singularity-forge/src/resources/extensions/gsd/sync-lock.ts
Jeremy McSpadden 1c0cca4f76 feat(gsd): single-writer state engine v2 — discipline layer on DB architecture
Ports the single-writer state architecture from PRs #2288–#2293 onto the
current upstream codebase (schema v10, polymorphic engine). Original PRs
were based on a pre-v5 schema with incompatible column names and predated
the WorkflowEngine interface refactor.

New files:
- workflow-events.ts: append-only event log (.gsd/event-log.jsonl)
- workflow-manifest.ts: full DB snapshot after every mutation (crash recovery)
- workflow-projections.ts: renders PLAN/ROADMAP/SUMMARY/STATE.md from DB
- workflow-migration.ts: migrates legacy markdown projects into DB
- workflow-reconcile.ts: event log replay for diverged worktrees
- workflow-logger.ts: structured error/warning accumulation
- sync-lock.ts: advisory lock for concurrent worktree syncs
- write-intercept.ts: blocks direct writes to STATE.md
- auto-artifact-paths.ts: central artifact path registry

Modified:
- All 8 tool handlers (complete-task, complete-slice, plan-slice, etc.)
  now wrap mutations in atomic transactions + emit event log + write
  manifest + regenerate markdown projections after every command
- state.ts: telemetry counters for DB vs filesystem derivation paths
- register-hooks.ts: write-intercept wired into tool_call hook
- doctor.ts/doctor-checks.ts/doctor-types.ts: engine health checks,
  fixable:false on completion-state issues, removed placeholder stubs
- auto.ts + supporting files: removed completedUnits tracking globally,
  removed unit-runtime record reads/writes, removed inline doctor runs
- auto-post-unit.ts: detectRogueFileWrites (6 unit types), removed
  doctor health tracking block, added regenerateIfMissing on retry
- 3 prompts updated to use gsd_* tool API instead of direct file edits

ADR-004: GSD had multiple writers racing to edit the same markdown files
concurrently, causing race conditions, stale reads, and corrupt state.
The single-writer discipline layer makes markdown files derived artifacts
(generated from DB after every command) rather than authoritative sources.

Supersedes closed PRs: #2288, #2289, #2290, #2291, #2292, #2293

AI assistance: implemented with Claude Code (GSD/Claude).
2026-03-25 08:53:02 -06:00

94 lines
2.9 KiB
TypeScript

// GSD Extension — Advisory Sync Lock
// Prevents concurrent worktree syncs from colliding via a simple file lock.
// Stale locks (mtime > 60s) are auto-overridden. Lock acquisition waits up
// to 5 seconds then skips non-fatally.
import { existsSync, statSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { atomicWriteSync } from "./atomic-write.js";
const STALE_THRESHOLD_MS = 60_000; // 60 seconds
const DEFAULT_TIMEOUT_MS = 5_000; // 5 seconds
const SPIN_INTERVAL_MS = 100; // 100ms polling interval
// SharedArrayBuffer for synchronous sleep via Atomics.wait
const SLEEP_BUFFER = new SharedArrayBuffer(4);
const SLEEP_VIEW = new Int32Array(SLEEP_BUFFER);
function lockFilePath(basePath: string): string {
return join(basePath, ".gsd", "sync.lock");
}
function sleepSync(ms: number): void {
Atomics.wait(SLEEP_VIEW, 0, 0, ms);
}
/**
* Acquire an advisory sync lock for the given basePath.
* Returns { acquired: true } on success, { acquired: false } after timeout.
*
* - Creates lock file at {basePath}/.gsd/sync.lock with JSON { pid, acquired_at }
* - If lock exists and mtime > 60s (stale), overrides it
* - If lock exists and not stale, spins up to timeoutMs before giving up
*/
export function acquireSyncLock(
basePath: string,
timeoutMs: number = DEFAULT_TIMEOUT_MS,
): { acquired: boolean } {
const lp = lockFilePath(basePath);
const deadline = Date.now() + timeoutMs;
while (true) {
// Check if lock file exists
if (existsSync(lp)) {
// Check staleness
try {
const stat = statSync(lp);
const age = Date.now() - stat.mtimeMs;
if (age > STALE_THRESHOLD_MS) {
// Stale lock — override it
try { unlinkSync(lp); } catch { /* race: already removed */ }
} else {
// Lock is held and not stale — wait or give up
if (Date.now() >= deadline) {
return { acquired: false };
}
sleepSync(SPIN_INTERVAL_MS);
continue;
}
} catch {
// stat failed (file removed between exists check and stat) — try to acquire
}
}
// Lock file does not exist (or was just removed) — try to write it
try {
const lockData = {
pid: process.pid,
acquired_at: new Date().toISOString(),
};
atomicWriteSync(lp, JSON.stringify(lockData, null, 2));
return { acquired: true };
} catch {
// Write failed (race condition with another process) — retry or give up
if (Date.now() >= deadline) {
return { acquired: false };
}
sleepSync(SPIN_INTERVAL_MS);
}
}
}
/**
* Release the advisory sync lock. No-op if lock file does not exist.
*/
export function releaseSyncLock(basePath: string): void {
const lp = lockFilePath(basePath);
try {
if (existsSync(lp)) {
unlinkSync(lp);
}
} catch {
// Non-fatal — lock may have been released by another process
}
}