Skip to content

T-SSB8-split-pr-respond-and-close-out-with-lease-reacquire

Status: closed/done · Impact: high · Complexity: medium

Extract /sdlc:pr-respond as a dedicated skill (it currently lives as Steps 10/12 of pre-cutover /sdlc:task-work) and wire both /sdlc:pr-respond and /sdlc:task-close-out to re-acquire the task lifecycle lease before performing any side effect. After this task lands, every PR review cycle has three lease-aware skills (task-work → pr-respond → task-close-out), each operating on a distinct lease phase, with the lease ref as the coordination boundary between them. Closes rollout item 7 of the ADR.

The split is what makes Model B from T-TZSN-task-work-lease-integration-model-b complete. Task-work exits at PR open with the lease in awaiting-review; pr-respond re-acquires that lease when review feedback arrives and rotates ownership to the responding worker; task-close-out re-acquires when the PR merges and finalizes the lease with a CAS-DELETE (or archive-then-delete per the ADR’s Release and archive section). Each skill is independently retryable, independently testable, and never trusts the calling context to hold a valid lease — the lease ref is the source of truth.

/sdlc:task-close-out exists as a standalone skill (carved out by T-RDKI-extract-task-close-out-skill under epic E0001) but is lease-unaware — it operates on git worktree list + frontmatter status: and trusts the caller to invoke it on a merged PR. No re-acquisition step exists; no CAS guard protects against two close-out workers racing on the same task. /sdlc:pr-respond does NOT yet exist as a standalone skill — the PR-feedback loop currently lives inside /sdlc:task-work (Step 12, “if review comments arrive while waiting for merge, respond and push”). The pre-cutover task-work is what handles the entire PR lifecycle; the new shape from T-TZSN-task-work-lease-integration-model-b removes that loop, so pr-respond has to land before (or at the same time as) the slice-2 cutover.

Today’s PR-feedback handling is a sub-agent invoked from inside task-work’s Step 12 prose: a Claude Code Agent call dispatched at the moment a review comment is detected. There’s no atomicity guard — if two operators happened to drive task-work for the same PR (unlikely but possible), both would attempt to push fixes against the same branch. The lease re-acquisition pattern in this task is what makes that race a CAS failure rather than a merge conflict.

This task consumes the branch-derivation contract from PR #118 and the acquire_lease helper from PR #124. The new mechanic is reacquire_lease(task_id, target_phase) — CAS-REPLACE-with-steal-if-expired semantics for transitioning an existing lease through phases without ever doing a fresh cas_create. No env vars, no argv lease state.

Extract /sdlc:pr-respond <pr-number> as a standalone skill. Lives at plugin/skills/pr-respond/SKILL.md. Behavior: given a PR number, the skill reads the PR body, extracts the <!-- sdlc-lease: task=<task-id> lease=<lease_id> --> footer (planted by T-TZSN-task-work-lease-integration-model-b), cd’s into the task’s worktree at .sdlc/worktrees/<task_id>, calls discover_lease() to read current state, validates that the discovered lease_id matches the footer (fencing check per the ADR’s Fencing and PR↔lease binding), and calls reacquire_lease(task_id, "responding") to CAS-REPLACE the lease into the responding phase. Only after the CAS succeeds does the skill apply review feedback (via an Agent sub-agent), heartbeat during work, push the response commits, and call reacquire_lease(task_id, "awaiting-review") to transition back. Exits with PR-RESPONSE-COMPLETE: pr=<n> task=<task-id>.

Teach /sdlc:task-close-out to re-acquire the lease. Existing close-out logic stays — tear down the worktree, delete the branches, write completion_note, flip status to closed/done — but is gated on a successful reacquire_lease(task_id, "closing") call. After all side effects complete, close-out calls a new release_to_archive(task_id) helper that atomically writes the final lease state to refs/sdlc/archive/tasks/<task-id> (via cas_create) and CAS-DELETE’s the active ref. If reacquire_lease fails at the start of close-out (another worker raced in), close-out exits cleanly with LEASE-CONFLICT and the operator decides whether to re-invoke.

Both skills follow the branch-derivation contract from PR #118. They derive task_id from current_task_id() once they’re cd’d into the worktree. No env vars are read; no argv lease state is consumed. Pr-respond additionally consumes the footer-derived lease_id for the fencing check (PR ↔ lease binding). If no lease ref exists for the task, both skills exit with LEASE-MISSING ref=<ref> and point the operator at task-work as the entry point.

Steal-on-expired semantics. reacquire_lease honors the ADR’s Steal an expired lease protocol: if the current lease’s expires_at <= now, the CAS-REPLACE proceeds with a rotated lease_token and the current host’s host_id as the new owner. This lets a long-stale awaiting-review lease be picked up by a fresh pr-respond or task-close-out worker without manual operator intervention.

No fallback path. Both skills fail closed if the lease library is unavailable or the lease ref’s state doesn’t match the expected phase. The pre-cutover task-close-out’s “trust the caller” path is removed; there is no opt-out flag.

  1. Add reacquire_lease(task_id, target_phase) to the lease library. Extends plugin/lib/lease/runtime.py. Logic: discover_lease() to get current state; build a new payload with phase=target_phase, owner=current_host_id(), rotated lease_token, fresh expires_at; cas_replace(authority, ref, current_sha, new_payload). If the current lease has expires_at <= now, log a STOLEN ref=<ref> from=<previous-owner> informational marker but proceed (per ADR steal-on-expired). On CAS-REPLACE failure (raced by another worker), raise LeaseConflict(ref, actual_owner). Library’s primitives write-through to the cache so the new state is observable to subsequent shell-outs.
  2. Add release_to_archive(task_id) to the lease library. New helper (likely in runtime.py or a new archive.py). Logic: cas_create(authority, f"refs/sdlc/archive/tasks/{task_id}", final_lease_commit); on success, cas_delete(authority, f"refs/sdlc/tasks/{task_id}", current_sha). If cas_create fails because the archive ref already exists (idempotent re-run of close-out), proceed to the delete step (the archive was already written). If cas_delete fails (raced), raise LeaseConflict — operator inspects.
  3. Add PR footer parsing. New helper in runtime.py exposing parse_lease_footer(pr_body: str) -> tuple[str, str] returning (task_id, lease_id). Regex-match against the exact format <!-- sdlc-lease: task=<id> lease=<lease_id> -->. Raises LeaseFooterMissing(pr_number=...) if not found or malformed.
  4. Scaffold /sdlc:pr-respond. Create plugin/skills/pr-respond/SKILL.md following the same prose shape as task-work (numbered steps, structured stdout markers, single sub-agent dispatch for the actual feedback work). The skill is invoked with /sdlc:pr-respond <pr-number>.
  5. Implement pr-respond Step 1: fetch PR body + parse footer. Shell out to gh pr view <pr-number> --json body -q .body, then call parse_lease_footer(). Missing/malformed → exit LEASE-FOOTER-MISSING pr=<n>.
  6. Implement pr-respond Step 2: cd into worktree. Worktree must exist at .sdlc/worktrees/<task_id> (task-work created it at PR open and didn’t tear it down). If absent → exit WORKTREE-MISSING task=<task_id> and point at task-work as the entry. After cd, the skill is on task/<task_id> branch.
  7. Implement pr-respond Step 3: fencing check + re-acquire. Call discover_lease(). Verify lease.lease_id == footer.lease_id — mismatch → exit LEASE-FENCING-MISMATCH expected=<footer_lease_id> actual=<discovered_lease_id> (the lease was rotated by another worker since this PR was opened; this PR no longer matches). On match, call reacquire_lease(task_id, "responding"). CAS-REPLACE failure → exit LEASE-CONFLICT ref=<ref> owner=<other-host-id>.
  8. Implement pr-respond Steps 4-6: response sub-agent + heartbeat + push + transition back. Spawn the heartbeat thread (same shape as task-work — threading.Thread(daemon=True) at TTL/2 cadence). Dispatch an Agent sub-agent to apply review feedback. After the sub-agent completes, push commits, signal heartbeat shutdown, and call reacquire_lease(task_id, "awaiting-review"). Exit PR-RESPONSE-COMPLETE: pr=<n> task=<task_id>.
  9. Wire lease re-acquisition into /sdlc:task-close-out. Add a new step at the top (after task resolution + worktree verification) calling reacquire_lease(task_id, "closing"). CAS-REPLACE failure → exit LEASE-CONFLICT. After existing close-out logic completes (worktree teardown, branch deletion, frontmatter closed/done, completion_note), call release_to_archive(task_id) as the final operation.
  10. Update /sdlc:orchestrate to dispatch pr-respond. The orchestrate skill from PR #118 already runs /sdlc:pr-check per PR; this task adds the dispatch step for the NEEDS-RESPONSE verdict — Agent invocation of /sdlc:pr-respond <pr-number> with no lease env (lease is footer + discovery). MERGED verdict still dispatches /sdlc:task-close-out <slug>.
  11. Tests. Library coverage: reacquire_lease happy path, expired-lease-steal path, cross-host-conflict path, missing-lease path; release_to_archive happy path, idempotent re-run path; parse_lease_footer happy / missing / malformed paths. Skill coverage: pr-respond fencing-mismatch and footer-missing paths; task-close-out reacquire-then-archive happy path and expired-lease steal path.
  12. Manual smoke test. End-to-end: task-work opens a PR (footer planted) → operator posts a review comment → orchestrate’s pr-check returns NEEDS-RESPONSE → orchestrate dispatches pr-respond → pr-respond re-acquires + responds + transitions back → operator merges → orchestrate’s pr-check returns MERGED → orchestrate dispatches task-close-out → close-out re-acquires + tears down + archives. Capture the lease ref’s commit history at each step (should show 4-6 CAS-REPLACE entries plus the final archive ref).
LocationKindChange
plugin/skills/pr-respond/SKILL.mdnewNew skill — parses PR footer, cd’s to worktree, fencing-checks via discover_lease, reacquire_lease(_, "responding"), dispatches response sub-agent, transitions back to awaiting-review
plugin/skills/pr-respond/test_pr_respond.pynewInline pytest — fencing-mismatch, footer-missing, worktree-missing, conflict paths
plugin/skills/pr-respond/invariants.yamlnewMirror the task-work pattern (lint-anchor cross-references)
plugin/skills/task-close-out/SKILL.mdmodifyAdd reacquire_lease(_, "closing") step at top of the lifecycle; add release_to_archive step at the end
plugin/skills/task-close-out/test_close_out_lease.pynewInline pytest — reacquire-then-archive happy path, expired-lease-steal path, missing-lease path
plugin/skills/orchestrate/SKILL.mdmodifyDispatch /sdlc:pr-respond <pr-number> on pr-check NEEDS-RESPONSE verdict (no lease env passed — pr-respond looks up the lease via footer + discover_lease)
plugin/lib/lease/runtime.pymodifyAdd reacquire_lease(task_id, target_phase) (CAS-REPLACE-with-steal-on-expired), release_to_archive(task_id) (atomic archive+delete), parse_lease_footer(pr_body)
plugin/lib/lease/tests/test_runtime.pymodifyAdd cases for reacquire_lease (happy / steal-on-expired / conflict / missing), release_to_archive (happy / idempotent-rerun), parse_lease_footer (happy / missing / malformed)
plugin/lib/lease/__init__.pymodifyRe-export reacquire_lease, release_to_archive, parse_lease_footer, LeaseFooterMissing
plugin/conventions/lease-aware-skills.mdmodifyDocument the re-acquire pattern, the fencing check (PR footer ↔ lease_id match), and the archive-ref convention (refs/sdlc/archive/tasks/<id>)
  • AC-1: /sdlc:pr-respond <pr-number> against a PR with a valid <!-- sdlc-lease: ... --> footer, an existing worktree, and a matching lease_id in the discovered lease: reacquire_lease(_, "responding") CAS-REPLACEs, response sub-agent runs, commits push, reacquire_lease(_, "awaiting-review") CAS-REPLACEs back. The lease ref’s commit history shows both transitions; exit marker PR-RESPONSE-COMPLETE: pr=<n> task=<task_id>.
  • AC-2: /sdlc:pr-respond <pr-number> against a PR whose body has no footer (or malformed footer) exits with LEASE-FOOTER-MISSING pr=<n>. No worktree mutation, no commit, no push.
  • AC-3: /sdlc:pr-respond <pr-number> against a PR whose footer’s lease_id doesn’t match the discovered lease (someone rotated the lease since this PR was opened) exits with LEASE-FENCING-MISMATCH expected=<footer_lease_id> actual=<discovered_lease_id> before any side effect.
  • AC-4: /sdlc:pr-respond <pr-number> against a lease held by another active worker (CAS-REPLACE fails) exits with LEASE-CONFLICT ref=<ref> owner=<other-host-id>.
  • AC-5: /sdlc:task-close-out <slug> calls reacquire_lease(_, "closing") before tearing down the worktree, deleting branches, or flipping frontmatter to closed/done. CAS-REPLACE failure exits with LEASE-CONFLICT and stops cleanly with no destructive operation.
  • AC-6: After successful close-out, an archive ref exists at refs/sdlc/archive/tasks/<task-id> pointing at the final lease commit, AND the active ref at refs/sdlc/tasks/<task-id> no longer exists. git show <archive-ref>:lease.json returns the final phase payload.
  • AC-7: /sdlc:task-close-out <slug> against a task with an expired lease successfully steals (re-acquires) the lease and proceeds, per the ADR’s Steal an expired lease protocol. An informational STOLEN ref=<ref> from=<previous-owner> marker is logged.
  • AC-8: /sdlc:task-close-out <slug> against a task with no lease ref exits with LEASE-MISSING ref=refs/sdlc/tasks/<task-id> and points the operator at running task-work or the cutover migration.
  • AC-9: /sdlc:orchestrate dispatches /sdlc:pr-respond <pr-number> for PRs that /sdlc:pr-check returns as NEEDS-RESPONSE. The dispatched Agent invocation receives the PR number as argv and NO lease-state env vars (pr-respond derives task_id/lease_id from the PR footer).
  • AC-10: release_to_archive(task_id) is idempotent — calling it twice in succession against the same task ID does not raise (the first call writes the archive ref + deletes the active ref; the second call finds the archive ref already exists and the active ref already gone, exits cleanly).
  • AC-11: No fallback. grep -rnE "if.*lease.*available|legacy.*close|trust.*caller" plugin/skills/pr-respond/ plugin/skills/task-close-out/ returns zero matches.
  • Background heartbeat thread management. Both skills heartbeat during their active phases; a long-running daemon that heartbeats unattended leases is a separate concern (likely lives in slice 3’s reconcile or in a dedicated worker process).
  • Reconcile of leases left in responding or closing phases by crashed workers. Slice 3.
  • Auto-merging PRs. This task does not change merge gating; the human still merges, and task-close-out fires after the merge is observed.
  • Multi-PR-per-task workflows. The ADR assumes one PR per task lifecycle; supporting multiple PRs is a future revision and not in this task.
  • Operation-lease re-acquisition (for backlog-triage, reconcile, import-planning operation leases). Slice 4 — different lease shape, different ergonomics.

Integration target: PRs from this task land on the lease-protocol-integration branch. Slice-2 cutover happens when the integration branch PRs to main with all three slice-2 tasks merged.

Realizes Rollout Plan item 7 of the ADR. The split is what makes lease ownership rotate cleanly across the PR lifecycle — pre-cutover, “the task is being worked” was a single state owned by one long-lived task-work sub-agent; post-cutover, it’s three states (working, awaiting-review, responding, closing) each owned by a distinct skill invocation that CAS-validates entry. The split also makes the protocol robust to operator restart: if pr-respond crashes mid-response, the next invocation re-acquires from responding cleanly (or steals if expired). The pre-cutover shape had no comparable recovery path — a crashed in-line response loop required manual operator intervention.

The two skills are bundled into one task (rather than four separate ones: extract-pr-respond, lease-wire-pr-respond, lease-wire-close-out, orchestrate-dispatch-respond) because they share the convention contract from T-X24I-orchestrate-lease-aware-dispatch and consume the same runtime.py helpers (discover_lease, acquire_lease from PR #124, and reacquire_lease + release_to_archive added by this task). Splitting further would multiply review surface without adding clarity. Per the user’s slice-2 directive, fewer-PRs-per-slice is the right trade for alpha-stage velocity.


← Back to Tasks