Skip to content

T-C5ZM-baseline-capture-per-sha-lock

Status: open/ready · Impact: medium · Complexity: small

AUTO-DEFINED: this spec was best-effort machine-authored by /sdlc:task-auto-define on 2026-08-03 because the task is autonomy: autonomous/pr. Review the Goal, Approach, Today, Files-to-touch, and Acceptance-criteria carefully before trusting it.

sdlc quality baseline capture caches its result by origin/main SHA but has nothing coordinating concurrent producers, so every /sdlc:task-work session that starts against the same SHA runs the full check suite itself instead of reusing the run already in flight. A per-SHA lock — late arrivals waiting on the in-flight capture rather than duplicating it — would collapse N redundant suite executions into one. This gap was surfaced upstream from T-HTN8-lease-gate-memo-fields on git@github.com:sksizer/dev.git.

Step 3a’s baseline capture ran for ~40 minutes because three other /sdlc:task-work sessions were capturing a baseline for the SAME origin/main SHA at the same time, each executing the full 11-verb suite — the cache is keyed on SHA but nothing coordinates concurrent producers. sdlc quality baseline capture should take a per-SHA lock and have late arrivals wait on the in-flight run rather than duplicate it.

T-HTN8-lease-gate-memo-fields

/sdlc:task-work Step 3a runs capture from the main checkout before the worktree is created, so concurrent sessions share one baseline directory at <project-root>/.sdlc/quality-baselines/. The capture core has no coordination and no cache short-circuit: it runs the declared verbs unconditionally and then writes the SHA-named file, so N sessions on the same SHA pay N full suites.

LocationRole today
apps/sdlc/lib/services/quality/baseline.ts#captureRuns every declared verb serially via runOne, then writes <baseline-dir>/<sha>.json and prunes. No existence check on the output path, no lock, no wait.
apps/sdlc/lib/services/quality/ops/baseline/capture.tsDepth-3 op wrapper that resolves paths and forwards to capture. Returns {path, sha}.
apps/sdlc/lib/services/quality/ops/run.tsThe gate-time reader: resolves <baseline-dir>/<sha>.json for --diff-against-baseline and errors when the file is absent.
apps/sdlc/lib/services/quality/tests/quality_ops.test.tsCovers the capture → diff → prune roundtrip single-threaded; has no concurrent-producer case.
apps/sdlc/lib/util/fs.tsFilesystem helpers (isFile, isDir, ensureDir, writeFileEnsuringDir, rglob). Carries no lock primitive.
apps/sdlc/skills/task-work/SKILL.mdStep 3a instructs the operator to invoke quality baseline capture for the resolved origin/main SHA on every run.

capture becomes a single-producer-per-SHA operation. The first caller for a given SHA acquires an exclusive on-disk lock beside the baseline file and runs the verbs; later callers for the same SHA block on that lock, and when it releases they find the finished <sha>.json and return its path without running anything. A caller that finds a completed baseline before doing any work returns immediately. Waiting is bounded and the failure direction is always “run it yourself” rather than “hang or error”, so a crashed or abandoned holder can never wedge a session.

  1. Add apps/sdlc/lib/services/quality/baseline-lock.ts exporting withBaselineLock(baselineDir, sha, body). Acquisition is mkdirSync(join(baselineDir, sha + ".lock")) — atomic on POSIX, fails with EEXIST when held. On success write a holder.json inside the lock directory carrying pid and an RFC3339 acquired_at (reuse rfc3339/nowUtc from @lib/util/date), run body, and remove the lock directory in a finally so a thrown verb error still releases it.
  2. On EEXIST, poll on a fixed interval (250 ms) until one of three things happens: the lock directory disappears (holder finished), the target <sha>.json appears (holder finished and wrote it), or the wait budget expires. Make the budget and the poll interval named module constants with the budget defaulting to 15 minutes, and expose both as optional parameters so the tests can drive short values.
  3. Treat a lock whose holder.json acquired_at is older than the wait budget as stale: remove the lock directory and retry acquisition once. This is the crashed-holder path; a single retry keeps it from looping.
  4. When the budget expires without the lock releasing, log one line on stderr naming the SHA and the holder pid, then run the body anyway. Capture is informational for task-work Step 3a, so degrading to a duplicate run is strictly better than failing the caller.
  5. Rework capture in apps/sdlc/lib/services/quality/baseline.ts: keep loadVerbList and mkdirSync(baselineDir) outside the lock, then short-circuit — if <baseline-dir>/<sha>.json already exists, return it without running verbs. Otherwise wrap the verb loop plus the write plus the prune call in withBaselineLock, and re-check for the finished file immediately after acquisition (the double-checked path a waiter takes).
  6. Add a .gitignore entry or confirm the existing .sdlc/ ignore already covers *.lock directories under .sdlc/quality-baselines/, so a stray lock never shows up in git status.
  7. Extend apps/sdlc/lib/services/quality/tests/quality_ops.test.ts with the concurrency cases described in the acceptance criteria, driving the short budget/interval parameters from step 2.
  8. Update apps/sdlc/skills/task-work/SKILL.md Step 3a so the operator knows capture may block while another session holds the lock, and that a wait is expected rather than a hang.
LocationKindChange
apps/sdlc/lib/services/quality/baseline-lock.tsnewwithBaselineLock — mkdir-based acquisition, bounded polling wait, stale-holder takeover, release in finally.
apps/sdlc/lib/services/quality/baseline.tsmodifycapture short-circuits on an existing <sha>.json, and wraps the verb loop + write + prune in withBaselineLock with a post-acquisition re-check.
apps/sdlc/lib/services/quality/tests/quality_ops.test.tsmodifyAdd the concurrent-producer, waiter-reuse, stale-lock, and release-on-throw cases.
apps/sdlc/skills/task-work/SKILL.mdmodifyStep 3a notes that capture may wait on another session’s in-flight run for the same SHA.
  • AC-1: A test in apps/sdlc/lib/services/quality/tests/quality_ops.test.ts runs two capture calls for the same SHA against a fixture whose verb appends one line to a counter file, and asserts the counter file holds exactly one line.
  • AC-2: A test asserts the second capture call from AC-1 returns the same path as the first and does not throw.
  • AC-3: A test that pre-creates <baseline-dir>/<sha>.lock with a holder.json whose acquired_at predates the wait budget asserts capture completes and writes <baseline-dir>/<sha>.json.
  • AC-4: A test that forces the wrapped body to throw asserts no <sha>.lock entry remains in the baseline directory afterward.
  • AC-5: A test asserts a capture call for a SHA whose <sha>.json already exists returns that path without invoking the fixture verb (counter file unchanged).
  • AC-6: The suite at apps/sdlc/lib/services/quality/tests/quality_ops.test.ts passes under bun test with exit code 0, including the pre-existing capture → diff → prune roundtrip cases.
  • Coordinating the read side (sdlc quality run --diff-against-baseline in apps/sdlc/lib/services/quality/ops/run.ts); this task locks producers only.
  • Cross-machine or networked locking. The baseline directory is local and gitignored, so an on-disk lock is sufficient.
  • Changing the baseline JSON payload shape, the finding-normalization masks, or the prune retention policy.
  • Sharing baselines between separate clones or between a clone and its worktrees.
  • Parallelizing the verbs inside a single capture run.
  • none

Spawned by /sdlc:spawn-task-pr on 2026-08-03 UTC from T-HTN8-lease-gate-memo-fields in git@github.com:sksizer/dev.git.


← Back to Tasks