forked from sethdford/shipwright
-
Notifications
You must be signed in to change notification settings - Fork 0
Pipeline Design 232
ezigus edited this page Mar 27, 2026
·
3 revisions
ADR written to .claude/pipeline-artifacts/design.md.
Summary of the design:
-
Root cause:
pipeline-tasks.mdis never cleared on initialize and never validated on resume or before injection — three independent gaps in the state lifecycle -
Fix: Defense-in-depth with 3 guards:
-
initialize_state()—rm -f TASKS_FILE(clean slate on new pipeline) -
resume_state()— parseIssue:metadata, remove if mismatched -
stage_build()— validateIssue:before injection, skip if stale
-
- Scope: ~18 production lines across 2 files, ~95 test lines, no new files or dependencies
-
Key risk mitigations: all
grepcalls use|| truefor pipefail safety,GITHUB_ISSUEhandled as potentially unset, malformed files treated as stale dates thatpipeline-tasks.mdbelongs to the same issue.
-
stage_build()(scripts/lib/pipeline-stages-build.sh:172) checks only-s "$TASKS_FILE"(file exists and is non-empty) — no metadata validation before injection.
The task file contains an Issue: metadata field (written at scripts/lib/pipeline-stages-intake.sh:460) that uniquely identifies which pipeline run created it, making validation straightforward.
- Bash 3.2 compatibility required (no associative arrays, no
${var,,}) - All scripts use
set -euo pipefail— safe patterns (|| true) needed for optional checks -
TASKS_FILEis in_GIT_BOOKKEEPING_FILES(never committed) — it's a transient build artifact - The fix must not break the happy path: same-issue resume must preserve valid tasks
Defense-in-depth with three independent validation points — clear on initialize, validate on resume, guard on inject.
┌──────────────────────────────────────────────────────────────────┐
│ Pipeline Lifecycle │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────┐ │
│ │ pipeline-state │ │ stages-intake │ │ stages-build │ │
│ │ │ │ │ │ │ │
│ │ initialize_state │ │ writes │ │ reads │ │
│ │ ► rm TASKS │ │ pipeline-tasks │ │ TASKS_FILE │ │
│ │ │ │ with Issue: meta │ │ validates │ │
│ │ resume_state │ │ │ │ Issue: meta │ │
│ │ ► validate │ │ │ │ before inject│ │
│ │ Issue: meta │ │ │ │ │ │
│ └────────┬─────────┘ └────────┬─────────┘ └──────┬───────┘ │
│ │ │ │ │
│ └───────────┬───────────┘ │ │
│ ▼ │ │
│ .claude/pipeline-tasks.md ──────────────────┘ │
│ (transient build artifact) │
└──────────────────────────────────────────────────────────────────┘
NEW PIPELINE:
initialize_state() ── rm -f TASKS_FILE ──► clean slate
intake stage ──────── writes TASKS_FILE with Issue: #NNN metadata
build stage ────────── validates Issue: matches GITHUB_ISSUE ──► injects into goal
RESUME (same issue):
resume_state() ──── reads Issue: from TASKS_FILE ── matches GITHUB_ISSUE ──► keeps file
build stage ─────── validates Issue: matches ──► injects into goal ✓
RESUME (different issue / stale file):
resume_state() ──── reads Issue: from TASKS_FILE ── mismatch ──► rm + warn
build stage ─────── TASKS_FILE gone ──► no injection ✓
// initialize_state() — scripts/lib/pipeline-state.sh
// Precondition: ARTIFACTS_DIR and TASKS_FILE are set
// Postcondition: pipeline-tasks.md removed, state variables reset
// Side effects: rm -f TASKS_FILE
function initialize_state(): void
// resume_state() — scripts/lib/pipeline-state.sh
// Precondition: STATE_FILE exists, TASKS_FILE may or may not exist
// Postcondition: if TASKS_FILE exists with mismatched Issue, file is removed + warning logged
// Error contract: missing/malformed Issue metadata → treat as stale (safe default), remove
function resume_state(): void // exits on fatal errors (missing state file, completed pipeline)
// stage_build() — scripts/lib/pipeline-stages-build.sh
// Precondition: TASKS_FILE and GITHUB_ISSUE may or may not be set
// Postcondition: TASKS_FILE content injected into enriched_goal ONLY if Issue matches
// Error contract: missing/malformed metadata → skip injection (defensive, no crash)
function stage_build(): void| Component | Error | Handling |
|---|---|---|
initialize_state() |
TASKS_FILE doesn't exist |
rm -f is idempotent — no error |
resume_state() |
TASKS_FILE missing Issue metadata | Treat as stale, remove with warn
|
resume_state() |
TASKS_FILE doesn't exist | No-op — [[ -f ]] guard |
stage_build() |
Issue extraction fails (grep returns 1) | ` |
stage_build() |
GITHUB_ISSUE unset | Skip validation, inject as before (backwards compat for goal-only pipelines) |
# Extract "Issue: #232" → "#232" from Context section
local tasks_issue
tasks_issue=$(grep '^- Issue:' "$TASKS_FILE" 2>/dev/null | head -1 | sed 's/^- Issue: *//' || true)
# Compare against current pipeline's issue
if [[ -n "$tasks_issue" && "$tasks_issue" \!= "${GITHUB_ISSUE:-}" ]]; then
warn "Stale pipeline-tasks.md (issue $tasks_issue ≠ ${GITHUB_ISSUE:-none}) — removing"
rm -f "$TASKS_FILE"
fi- Metadata-Scoped Task Validation (commit hash + tree hash) — Pros: intelligent per-task filtering, future-proof for multi-issue pipelines / Cons: ~50+ lines of complexity, overkill since pipelines are single-issue, introduces git dependency in state management, hard to test
-
Centralized
validate_tasks_file()helper — Pros: single source of truth, DRY / Cons: ~30 lines of overhead for a localized 3-point bug, adds indirection that obscures simplicity. Defense-in-depth across lifecycle points is more robust than a single validation call because each guard catches different failure modes (stale from prior run vs. stale from issue switch vs. stale at injection time)
- Files to create: none
-
Files to modify:
-
scripts/lib/pipeline-state.sh—initialize_state()(+1 line:rm -f),resume_state()(+11 lines: parse Issue metadata + validate + remove if stale) -
scripts/lib/pipeline-stages-build.sh—stage_build()(+6 lines: validate Issue metadata before injection at line 172) -
scripts/sw-lib-pipeline-stages-test.sh— 4 new test cases (~95 lines: initialize clears tasks, resume validates issue match, resume removes on mismatch, build skips stale injection)
-
-
Dependencies: none — all changes use existing variables (
TASKS_FILE,GITHUB_ISSUE) and utilities (warn,info,grep) -
Risk areas:
-
grepunderset -euo pipefailexits 1 on no match — all grep calls must use|| true -
GITHUB_ISSUEmay be unset/empty in goal-only pipelines (no--issueflag) — guards must handle${GITHUB_ISSUE:-} - Task file may lack a Context section entirely (malformed) — treat as stale and remove
-
-
[99% — CONFIRMED]
pipeline-tasks.mdnot cleared/validated on resume — three independent gaps in the state lifecycle. Evidence: direct code inspection ofinitialize_state()(line 461 clears 2 files, not tasks),resume_state()(lines 534-610, no TASKS_FILE reference), andstage_build()(line 172,-scheck only). -
[85%]
TEST_CMDenvironment leak from test harness —export TEST_CMD="echo FAIL; exit 1"atsw-lib-pipeline-stages-test.sh:253could persist across test boundaries. Secondary to stale tasks; investigate during build. -
[70%] Incomplete transient artifact cleanup pattern —
initialize_state()only clears 2 of N transient files, suggesting this class of bug could recur for other artifacts.
-
scripts/lib/pipeline-state.sh:452-463—initialize_state()clearsmodel-routing.logand.plan-failure-sig.txtonly -
scripts/lib/pipeline-state.sh:534-610—resume_state()restoresGITHUB_ISSUEat line 553 but never touchesTASKS_FILE -
scripts/lib/pipeline-stages-build.sh:172-177— injection guarded only by[[ -s "$TASKS_FILE" ]] -
scripts/lib/pipeline-stages-intake.sh:460— metadata line- Issue: ${GITHUB_ISSUE:-none}is parseable
Defense-in-depth at three lifecycle points rather than a single validation call. Each guard is independent — if one is bypassed (e.g., direct resume without initialize), the others catch stale tasks. This matches the existing pattern where initialize_state() already clears per-run artifacts.
- Unit test: create TASKS_FILE, call
initialize_state(), assert file removed - Unit test: create TASKS_FILE with
Issue: #154, setGITHUB_ISSUE="#232", callresume_state(), assert file removed - Unit test: create TASKS_FILE with
Issue: #232, setGITHUB_ISSUE="#232", callresume_state(), assert file preserved - Unit test: create TASKS_FILE with wrong issue, call
stage_build(), assert not injected into goal - Integration: run
./scripts/sw-pipeline-test.sh— all 50+ existing tests pass (no regressions)
-
initialize_state()removespipeline-tasks.mdon every new pipeline start -
resume_state()removes task file when Issue metadata doesn't match currentGITHUB_ISSUE -
resume_state()preserves task file when Issue metadata matches currentGITHUB_ISSUE -
stage_build()skips injection when Issue metadata doesn't match - Malformed/missing metadata in task file doesn't crash pipeline (graceful degradation)
- Missing task file on resume is a no-op (no crash)
- All existing pipeline tests pass with no regressions
- All code is Bash 3.2 compatible