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).
94 lines
2.9 KiB
TypeScript
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
|
|
}
|
|
}
|