T-TZSN-task-work-lease-integration-model-b
Status: closed/done · Impact: high · Complexity: large
Convert /sdlc:task-work from a single long-lived skill that runs the entire task → PR → review →
close-out cycle into a lease-aware skill that exits at PR open. After this task lands, task-work (a)
refuses to start without a valid task lifecycle lease (acquired via acquire_lease() from the lease
library, either by cas_create for operator-direct invocations or by validating same-host ownership
of a pre-existing ref for orchestrate-dispatched invocations), (b) CAS-REPLACE’s the lease ref on
every phase transition with rotated token semantics from the
Phase transitions section
of the ADR, (c) heartbeats the lease during active work, (d) writes handoff.md into the lease
commit at PR-open, (e) embeds the <!-- sdlc-lease: task=<id> lease=<lease_id> --> footer in the PR
body for fencing, and (f) exits cleanly the moment gh pr create succeeds. The downstream
/sdlc:pr-respond and /sdlc:task-close-out skills take over from there (handled by
T-SSB8-split-pr-respond-and-close-out-with-lease-reacquire).
This is the largest cutover in slice 2: task-work today owns the entire task lifecycle, and Model B carves it into thirds. Per the user’s slice-2 directive, this is a one-shot hard cutover with no fallback or dual-mode path — task-work after this task is unrunnable without a lease.
/sdlc:task-work (defined in plugin/skills/task-work/SKILL.md) is a 13-step skill that does
everything: resolve task → preflight → relevance check → create worktree → ensure-ready gate → flip
status to in-progress (start-commit on main) → dispatch implementation sub-agent → run quality
checks → post-mortem → spawn follow-ups → push branch → open PR → (Step 11) wait for review and
close out the task. Coordination is via status: frontmatter — flipping a task to in-progress is
the claim, with no atomic guarantee. There is no lease ref; the task branch is the only artifact of
“this task is being worked.” handoff.md does not exist. PR bodies have no lease footer. Step 11
(close-out) inlines worktree teardown + branch deletion + frontmatter close-out into the same skill
that opened the PR.
The skill is invoked directly by the operator (/sdlc:task-work <slug>) and by /sdlc:orchestrate
via Agent dispatch. After T-X24I-orchestrate-lease-aware-dispatch lands, orchestrate-driven
invocations arrive with the lease already CAS-CREATEd at refs/sdlc/tasks/<id> (and the
write-through cache populated); operator-driven invocations arrive with no lease at all. Both paths
funnel through one acquire_lease(task_id) helper that handles either case uniformly.
Proposed
Section titled “Proposed”Six structural changes to /sdlc:task-work. The mechanics consume the branch-derivation discovery +
write-through cache contract from T-X24I-orchestrate-lease-aware-dispatch — no env vars, no argv
lease state.
1. Mandatory lease acquisition at the right boundary. Before any side effect (worktree creation,
branch push, PR open), task-work acquires the task’s lifecycle lease via a new
runtime.acquire_lease(task_id) -> TaskLifecycleLease helper. Logic: attempt cas_create on
refs/sdlc/tasks/<task_id>; if it succeeds, we own a fresh lease (operator-direct path). If
cas_create returns CASFailed (the ref already exists), we’re in the orchestrate-dispatched case
where the dispatcher already claimed — fetch the ref and verify owner == current_host_id(). Match
→ we proceed (same host, legitimate inheritance). Mismatch → exit with
LEASE-CONFLICT ref=<ref> owner=<other-host-id> before any side effect. The acquisition happens
after task-file resolution + pre-flight but BEFORE worktree creation, so the unique-acquisition
point is the entry to the destructive steps.
2. Phase transitions become CAS-REPLACE operations. Existing status transitions (open/ready →
in-progress at start-commit; in-progress → awaiting-review at PR open) translate into cas_replace
on the lease ref. The library’s primitives from PR #118 write-through to the on-disk cache
automatically, so subsequent shell-outs in the same workflow read fresh state without a network
round-trip. Phase-transition matrix follows the ADR’s
Phase transitions section:
same-owner transitions preserve lease_token; ownership-change transitions rotate it.
3. Heartbeat during active work. While the implementation sub-agent (current Step 6) is running,
the lease needs heartbeats more frequent than TTL/4 (per the ADR; slice 1’s CLI enforces a floor).
Concrete choice for this task: in-skill heartbeat thread launched as a background daemon when
implementation begins, joined when implementation completes. Use threading.Thread(daemon=True)
calling cas_replace at half the configured TTL. Documented as the canonical shape in the
convention doc; future operation-lease work may need different scheduling but task-work’s pattern is
fixed here.
4. handoff.md generation at PR open. Before gh pr create, task-work composes a handoff.md
capturing what the implementing sub-agent learned (task summary, files changed, quality-check
status, follow-ups spawned, anything a successor pr-respond / close-out worker would need to know).
The library’s build_lease_commit(..., handoff_md=...) helper from slice 1 writes it into the lease
commit; this task wires the content generation via a new handoff.compose_handoff_md(...) helper.
5. PR body footer. Append <!-- sdlc-lease: task=<task-id> lease=<lease_id> --> (HTML comment,
invisible in rendered PR but greppable in the raw body) as the last line of every
gh pr create --body. The ADR’s
Fencing and PR↔lease binding
section relies on this; /sdlc:pr-respond reads it to verify the correct task.
6. Exit at PR open. Steps 10 (post-mortem after merge), 11 (close-out), and 12 (PR-feedback
loop) are removed from plugin/skills/task-work/SKILL.md entirely. After Step 9 (PR opened + lease
transitioned to awaiting-review with expires_at: null), task-work exits with
TASK-WORK-COMPLETE: <slug> PR=<url>. The lease ref retains awaiting-review state; the PR holds
the footer; handoff.md is in the lease commit. From the orchestrator’s point of view, the
sub-agent returned successfully and the lease is waiting for a downstream worker.
No fallback path. If the lease library is unavailable (import error, missing module), task-work
exits at Step 1 with a structured stderr line. If discover_lease() raises InvalidBranchForLease
(somehow the worktree isn’t on a task/* branch), task-work exits before any further step. The
skill prose does not document or implement a “lease-disabled mode.”
Approach
Section titled “Approach”-
Read the current
/sdlc:task-workSKILL.md end-to-end and map every existing step to: kept-as-is, modified-for-lease, removed (moved to pr-respond/close-out), or new-for-lease. Capture the map in a top-of-doc table in the rewritten SKILL.md so future readers can compare the before/after shape. -
Consume the branch-derivation contract from T-X24I-orchestrate-lease-aware-dispatch.
plugin/conventions/lease-aware-skills.md(created in PR #118) documentsdiscover_lease()semantics, the write-through cache, the validate-before-side-effect rule, and the fail-closed rule. Task-work is the first major skill to consume the convention. -
Add
runtime.acquire_lease(task_id)to the lease library. Extendsplugin/lib/lease/runtime.py(created in PR #118). Logic: trycas_create(authority, f"refs/sdlc/tasks/{task_id}", initial_payload); if it succeeds, return the fresh lease (operator-direct path). OnCASFailed,fetch_refthe existing lease and checklease.owner == current_host_id(); match returns the existing lease (orchestrate-dispatched path — dispatcher already claimed). Mismatch raisesLeaseConflict(ref, owner). Both success paths populate the cache via the primitives’ write-through. -
Implement Step 1 lease acquisition in task-work. After task-file resolution (current Step 1) and pre-flight (current Step 2), insert a new step calling
acquire_lease(task_id). The skill prose says: “Ifacquire_leaseraisesLeaseConflict, exit cleanly withLEASE-CONFLICT ref=<ref> owner=<other-host-id>— no worktree, no branch, no PR.” Acquisition must happen BEFORE worktree creation; the lease is what authorizes the destructive steps. -
Wire CAS-REPLACE into every status transition. open/ready → in-progress (start-commit on main) → CAS-REPLACE phase to
working; in-progress → awaiting-review (at PR open) → CAS-REPLACE phase toawaiting-review, setexpires_at: null. Each transition is a library call (not a CLI subprocess) because we’re already in Python; the library’s write-through cache means the lease state is mirrored locally with no extra I/O. Phase-token rotation follows the ADR’s matrix. -
Heartbeat thread. When the implementation sub-agent step starts, spawn a
threading.Thread(daemon=True)whose target loops:time.sleep(TTL/2); callcas_replaceto bumpexpires_at; check athreading.Eventfor shutdown. The implementation step joins/signals shutdown on completion. Floor enforcement still lives in the library (slice 1 contract). -
Generate
handoff.mdcontent. New library fileplugin/lib/lease/handoff.pyexposingcompose_handoff_md(task_id, task_title, summary, files_changed, quality_check_status, pr_url, spawned_followups) -> strOutput is deterministic from inputs (no host paths, no relative timestamps; all timestamps are ISO-8601 UTC). Wire it into task-work’s Step 9 just before
gh pr create. -
Update PR body construction. Step 9’s
gh pr create --bodygets a deterministic footer appended. Format is exact:<!-- sdlc-lease: task=<task-id> lease=<lease_id> -->on its own line, immediately before EOF, no surrounding whitespace. Composed by a small helper to keep the format in one place. -
Remove Steps 10–12. Delete the post-mortem-after-merge, the close-out prose, and the PR-feedback loop from
plugin/skills/task-work/SKILL.md. The replacement skills are T-SSB8-split-pr-respond-and-close-out-with-lease-reacquire. Update theinvariants.yamlcross-references if the section anchors change. -
Update
start_task.pyto perform the lease’sworkingtransition. Currently it flips frontmatter; now it also callscas_replaceto transition the lease fromclaimedtoworking. Reuses the library helper (don’t shell out to CLI from Python). -
Tests. Inline pytest files alongside existing skill scripts (
plugin/skills/task-work/test_lease_integration.py). Cover: (a) operator-direct acquire path (no pre-existing lease → cas_create succeeds), (b) dispatcher-inherited path (pre-existing same-host lease → discover-and-validate), (c) cross-host pre-existing lease →LEASE-CONFLICTexit before any side effect, (d) PR body contains the footer byte-exact, (e)handoff.mdlands in the lease commit (verified viagit show <lease-ref>:handoff.md), (f) skill flow ends at PR open (no Steps 10–12 remnants). -
Manual smoke test. Run task-work end-to-end against a local bare-repo authority + a dummy task. Verify the lease ref’s commit history shows: CAS-CREATE at acquire, CAS-REPLACE at start-commit (working), CAS-REPLACE for each heartbeat, CAS-REPLACE at PR-open (awaiting-review) with handoff.md in the tree.
Files to touch
Section titled “Files to touch”| Location | Kind | Change |
|---|---|---|
plugin/skills/task-work/SKILL.md | modify | Major rewrite: insert lease-acquisition step (post pre-flight, pre worktree); CAS-REPLACE on each phase transition; handoff.md generation; PR body footer; exit at PR open; remove Steps 10–12 |
plugin/skills/task-work/start_task.py | modify | Call cas_replace to transition lease phase to working alongside the existing frontmatter flip |
plugin/skills/task-work/test_start_task.py | modify | Update existing tests + add new cases covering the lease transition |
plugin/skills/task-work/test_lease_integration.py | new | New pytest file: 6 cases covering acquire / inherit / cross-host-conflict / footer / handoff / exit-at-PR-open |
plugin/skills/task-work/invariants.yaml | modify | Update any section anchors invalidated by Step 10–12 removal |
plugin/lib/lease/runtime.py | modify | Add acquire_lease(task_id) -> TaskLifecycleLease (try cas_create, on CASFailed verify same-host ownership, else raise LeaseConflict) and current_host_id() -> str |
plugin/lib/lease/handoff.py | new | compose_handoff_md(task_id, title, summary, files_changed, quality_status, pr_url, spawned_followups) -> str; deterministic output |
plugin/lib/lease/tests/test_runtime.py | modify | Add acquire-lease coverage: fresh-create path, same-host-inherit path, cross-host-conflict path |
plugin/lib/lease/tests/test_handoff.py | new | Deterministic-output assertion: same inputs → byte-identical output |
plugin/lib/lease/__init__.py | modify | Re-export acquire_lease, current_host_id, LeaseConflict, and the handoff module |
plugin/conventions/lease-aware-skills.md | modify | Extend with the heartbeat-thread shape (in-skill daemon, TTL/2 cadence) and the acquire-lease semantics (cas_create-or-validate) |
Acceptance criteria
Section titled “Acceptance criteria”- AC-1: Operator-direct invocation (
/sdlc:task-work <slug>against a clean authority) succeeds by callingacquire_lease(task_id), whichcas_createsrefs/sdlc/tasks/<task_id>. Verified by inspecting the lease ref’s commit history shows exactly one entry after the run + matching state in the cache file. - AC-2: When the lease ref already exists with
owner == current_host_id()(orchestrate-dispatched case where the dispatcher already claimed on the same host),acquire_leasedoes NOT re-create — itfetch_refs, validates the owner match, and returns the existing payload. Verified by mocking the dispatcher’s CAS-CREATE and asserting task-work’s subsequent CAS-CREATE attempt fails CAS, falls through to fetch, and returns. - AC-3: When the lease ref exists with
owner != current_host_id()(another host already holds it), task-work exits withLEASE-CONFLICT ref=refs/sdlc/tasks/<id> owner=<other-host-id>BEFORE any side effect (no worktree, no branch, no PR). - AC-4: Every status transition during the task-work flow performs a CAS-REPLACE on the lease ref. After a smoke run the lease ref’s commit history shows: CAS-CREATE (acquire) → CAS-REPLACE (transition to working at start) → ≥1 CAS-REPLACE (heartbeat) → CAS-REPLACE (transition to awaiting-review at PR open).
- AC-5: The PR body created by
gh pr createends with the literal line<!-- sdlc-lease: task=<task-id> lease=<lease_id> -->(HTML comment, exact format, immediately before EOF, no trailing newline beyond the standard one). - AC-6: The lease commit at PR-open time contains a
handoff.mdblob with deterministic content (same inputs → byte-identical output). Verified bygit show <lease-ref>:handoff.mdagainst a fixture string. - AC-7: Task-work exits cleanly with
TASK-WORK-COMPLETE: <slug> PR=<url>immediately aftergh pr createsucceeds. The skill does NOT wait for review, does NOT respond to feedback, does NOT close out.plugin/skills/task-work/SKILL.mdno longer contains Steps 10, 11, or 12 (the heading or numbering) from the pre-cutover shape. - AC-8: Lease library unavailable at startup (e.g., import error) causes task-work to exit with a structured stderr line; no side effect attempted.
- AC-9: No fallback path.
grep -rnE "if.*lease.*available|legacy.*mode|fallback (?:to|for) (?:legacy|inline|env)" plugin/skills/task-work/returns zero matches. - AC-10: Heartbeat thread is implemented as a
threading.Thread(daemon=True)started before the implementation step and joined after; verified by reading the SKILL.md prose and by a unit test that mockscas_replaceand asserts ≥2 calls during a fixture-controlled sleep window.
Out of scope
Section titled “Out of scope”/sdlc:pr-respondand/sdlc:task-close-out. Those are T-SSB8-split-pr-respond-and-close-out-with-lease-reacquire. This task removes Steps 10–12 from task-work; the next task implements the replacements.- Migrating already-running task-work flows mid-flight. The cutover requires that no task-work sub-agent is active at the moment slice 2 lands. The operator quiesces the orchestrator before merging the slice-2 integration branch.
- Operation leases for non-task workflows. Slice 4.
- Reconcile of stale leases. Slice 3. This task only writes leases; reconcile reads them.
Dependencies
Section titled “Dependencies”- T-S0PK-add-lease-protocol-library-and-schemas — library + schemas +
build_lease_commit - T-QC31-add-sdlc-lease-cli-commands —
sdlc lease task claim/sdlc lease task transition/sdlc lease heartbeat/sdlc lease release - T-X24I-orchestrate-lease-aware-dispatch — supplies the lease library’s branch-derivation
discovery (
runtime.discover_lease), the write-through on-disk cache, and the convention doc (plugin/conventions/lease-aware-skills.md) extended by this task
Integration target: PRs from this task land on the lease-protocol-integration branch, not
main. Sibling tasks merge into the same integration branch; the branch is PR’d to main as one
cutover.
Discovery context
Section titled “Discovery context”Realizes Rollout Plan item 6 of the ADR. This is the largest task in slice 2 because task-work’s
existing shape conflates work that the protocol intentionally separates (claim → work → PR → review
→ merge → close-out). Carving task-work down to claim-through-PR-open is what enables the rest of
the protocol’s safety properties: a crashed task-work sub-agent leaves an active awaiting-review
lease that another worker can steal, rather than a half-merged commit history with no recovery path.
The “no fallback” directive is load-bearing here. The pre-cutover task-work has been working without
leases since the project started; the simplest safe rewrite would have been to add an opt-in
--with-lease flag and keep the old path as default. The user’s call to skip that adds risk
(operator misconfiguration is harder to diagnose without a fallback) but removes the bigger risk
(dual-mode behavior that nobody tests in the same run, leading to drift). Alpha-stage rationale: the
fewer modes, the fewer bugs.