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 captureshould take a per-SHA lock and have late arrivals wait on the in-flight run rather than duplicate it.
/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.
| Location | Role today |
|---|---|
apps/sdlc/lib/services/quality/baseline.ts#capture | Runs 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.ts | Depth-3 op wrapper that resolves paths and forwards to capture. Returns {path, sha}. |
apps/sdlc/lib/services/quality/ops/run.ts | The 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.ts | Covers the capture → diff → prune roundtrip single-threaded; has no concurrent-producer case. |
apps/sdlc/lib/util/fs.ts | Filesystem helpers (isFile, isDir, ensureDir, writeFileEnsuringDir, rglob). Carries no lock primitive. |
apps/sdlc/skills/task-work/SKILL.md | Step 3a instructs the operator to invoke quality baseline capture for the resolved origin/main SHA on every run. |
Proposed
Section titled “Proposed”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.
Approach
Section titled “Approach”- Add
apps/sdlc/lib/services/quality/baseline-lock.tsexportingwithBaselineLock(baselineDir, sha, body). Acquisition ismkdirSync(join(baselineDir, sha + ".lock"))— atomic on POSIX, fails withEEXISTwhen held. On success write aholder.jsoninside the lock directory carryingpidand an RFC3339acquired_at(reuserfc3339/nowUtcfrom@lib/util/date), runbody, and remove the lock directory in afinallyso a thrown verb error still releases it. - On
EEXIST, poll on a fixed interval (250 ms) until one of three things happens: the lock directory disappears (holder finished), the target<sha>.jsonappears (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. - Treat a lock whose
holder.jsonacquired_atis 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. - 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.
- Rework
captureinapps/sdlc/lib/services/quality/baseline.ts: keeploadVerbListandmkdirSync(baselineDir)outside the lock, then short-circuit — if<baseline-dir>/<sha>.jsonalready exists, return it without running verbs. Otherwise wrap the verb loop plus the write plus theprunecall inwithBaselineLock, and re-check for the finished file immediately after acquisition (the double-checked path a waiter takes). - Add a
.gitignoreentry or confirm the existing.sdlc/ignore already covers*.lockdirectories under.sdlc/quality-baselines/, so a stray lock never shows up ingit status. - Extend
apps/sdlc/lib/services/quality/tests/quality_ops.test.tswith the concurrency cases described in the acceptance criteria, driving the short budget/interval parameters from step 2. - Update
apps/sdlc/skills/task-work/SKILL.mdStep 3a so the operator knows capture may block while another session holds the lock, and that a wait is expected rather than a hang.
Files to touch
Section titled “Files to touch”| Location | Kind | Change |
|---|---|---|
apps/sdlc/lib/services/quality/baseline-lock.ts | new | withBaselineLock — mkdir-based acquisition, bounded polling wait, stale-holder takeover, release in finally. |
apps/sdlc/lib/services/quality/baseline.ts | modify | capture 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.ts | modify | Add the concurrent-producer, waiter-reuse, stale-lock, and release-on-throw cases. |
apps/sdlc/skills/task-work/SKILL.md | modify | Step 3a notes that capture may wait on another session’s in-flight run for the same SHA. |
Acceptance criteria
Section titled “Acceptance criteria”- AC-1: A test in
apps/sdlc/lib/services/quality/tests/quality_ops.test.tsruns twocapturecalls 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
capturecall from AC-1 returns the same path as the first and does not throw. - AC-3: A test that pre-creates
<baseline-dir>/<sha>.lockwith aholder.jsonwhoseacquired_atpredates the wait budget assertscapturecompletes and writes<baseline-dir>/<sha>.json. - AC-4: A test that forces the wrapped body to throw asserts no
<sha>.lockentry remains in the baseline directory afterward. - AC-5: A test asserts a
capturecall for a SHA whose<sha>.jsonalready 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.tspasses underbun testwith exit code 0, including the pre-existing capture → diff → prune roundtrip cases.
Out of scope
Section titled “Out of scope”- Coordinating the read side (
sdlc quality run --diff-against-baselineinapps/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
pruneretention policy. - Sharing baselines between separate clones or between a clone and its worktrees.
- Parallelizing the verbs inside a single capture run.
Dependencies
Section titled “Dependencies”- none
Discovery context
Section titled “Discovery context”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.