Skip to content

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.

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.”

  1. Read the current /sdlc:task-work SKILL.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.

  2. Consume the branch-derivation contract from T-X24I-orchestrate-lease-aware-dispatch. plugin/conventions/lease-aware-skills.md (created in PR #118) documents discover_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.

  3. Add runtime.acquire_lease(task_id) to the lease library. Extends plugin/lib/lease/runtime.py (created in PR #118). Logic: try cas_create(authority, f"refs/sdlc/tasks/{task_id}", initial_payload); if it succeeds, return the fresh lease (operator-direct path). On CASFailed, fetch_ref the existing lease and check lease.owner == current_host_id(); match returns the existing lease (orchestrate-dispatched path — dispatcher already claimed). Mismatch raises LeaseConflict(ref, owner). Both success paths populate the cache via the primitives’ write-through.

  4. 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: “If acquire_lease raises LeaseConflict, exit cleanly with LEASE-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.

  5. 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 to awaiting-review, set expires_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.

  6. Heartbeat thread. When the implementation sub-agent step starts, spawn a threading.Thread(daemon=True) whose target loops: time.sleep(TTL/2); call cas_replace to bump expires_at; check a threading.Event for shutdown. The implementation step joins/signals shutdown on completion. Floor enforcement still lives in the library (slice 1 contract).

  7. Generate handoff.md content. New library file plugin/lib/lease/handoff.py exposing

    compose_handoff_md(task_id, task_title, summary, files_changed, quality_check_status, pr_url, spawned_followups) -> str

    Output 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.

  8. Update PR body construction. Step 9’s gh pr create --body gets 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.

  9. 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 the invariants.yaml cross-references if the section anchors change.

  10. Update start_task.py to perform the lease’s working transition. Currently it flips frontmatter; now it also calls cas_replace to transition the lease from claimed to working. Reuses the library helper (don’t shell out to CLI from Python).

  11. 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-CONFLICT exit before any side effect, (d) PR body contains the footer byte-exact, (e) handoff.md lands in the lease commit (verified via git show <lease-ref>:handoff.md), (f) skill flow ends at PR open (no Steps 10–12 remnants).

  12. 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.

LocationKindChange
plugin/skills/task-work/SKILL.mdmodifyMajor 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.pymodifyCall cas_replace to transition lease phase to working alongside the existing frontmatter flip
plugin/skills/task-work/test_start_task.pymodifyUpdate existing tests + add new cases covering the lease transition
plugin/skills/task-work/test_lease_integration.pynewNew pytest file: 6 cases covering acquire / inherit / cross-host-conflict / footer / handoff / exit-at-PR-open
plugin/skills/task-work/invariants.yamlmodifyUpdate any section anchors invalidated by Step 10–12 removal
plugin/lib/lease/runtime.pymodifyAdd 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.pynewcompose_handoff_md(task_id, title, summary, files_changed, quality_status, pr_url, spawned_followups) -> str; deterministic output
plugin/lib/lease/tests/test_runtime.pymodifyAdd acquire-lease coverage: fresh-create path, same-host-inherit path, cross-host-conflict path
plugin/lib/lease/tests/test_handoff.pynewDeterministic-output assertion: same inputs → byte-identical output
plugin/lib/lease/__init__.pymodifyRe-export acquire_lease, current_host_id, LeaseConflict, and the handoff module
plugin/conventions/lease-aware-skills.mdmodifyExtend with the heartbeat-thread shape (in-skill daemon, TTL/2 cadence) and the acquire-lease semantics (cas_create-or-validate)
  • AC-1: Operator-direct invocation (/sdlc:task-work <slug> against a clean authority) succeeds by calling acquire_lease(task_id), which cas_creates refs/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_lease does NOT re-create — it fetch_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 with LEASE-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 create ends 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.md blob with deterministic content (same inputs → byte-identical output). Verified by git show <lease-ref>:handoff.md against a fixture string.
  • AC-7: Task-work exits cleanly with TASK-WORK-COMPLETE: <slug> PR=<url> immediately after gh pr create succeeds. The skill does NOT wait for review, does NOT respond to feedback, does NOT close out. plugin/skills/task-work/SKILL.md no 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 mocks cas_replace and asserts ≥2 calls during a fixture-controlled sleep window.
  • /sdlc:pr-respond and /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.

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.

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.


← Back to Tasks