career/qwestly/2026-09-asana-ticket-lifecycle.md

Ticket lifecycle cleanup: making Asana status reflect real work

Situation

Qwestly runs its engineering work from one rolling Asana board, "Current Sprint". Each ticket has a Task Progress field meant to show where the work stands. In practice, nobody could look at the board and know what was being worked on, what was blocked, or what had shipped. 95% of open tickets had no status, and many finished tickets were left at an earlier state such as "PR Created". The field had ten options. Three of them were almost never used, and the rest were set only when someone remembered. Most of the work now runs through coding agents, so a status that depends on human memory falls further behind the work every week.

What I did

  • Built the Asana release sync (live 2026-09-29, workspace PR #117): after each production release it comments on every ticket whose PRs shipped and sets Deployed once every linked PR is in main, so the team knows what went out without asking. The backfill over every release since release automation began covered 49 releases, posted 42 comments and set 35 tickets Deployed, with no errors.
  • Measured the board before changing anything: all 487 tasks on Current Sprint, plus usage of every option across the workspace. The analysis showed the gap was in adoption, not in the option list. Status changes that depended on someone remembering them were skipped far more often than anyone expected.
  • Proposed a smaller lifecycle tied to our actual workflow:
    • blank means not started;
    • In Progress → Validating (agent) → QA (human, optional) → PR Created → In Staging → Deployed;
    • QA is the human gate for work an agent can't fully validate: the product owner or a teammate tests the feature branch's preview deployment before the PR is published. It exists because we treat staging as near-production (any staging merge can ship at the next release), so anything needing human judgement has to be checked before it merges;
    • PR Created means a published PR ready for review; draft PRs used for CI stay in Validating or QA;
    • Waiting for a blocker, with a required comment saying who or what it waits on;
    • Deferred for work the team decided to take off the active board, with a required comment giving the reasoning, then moved to the backlog or linked as blocked by the ticket that must ship first;
    • Done only for work that has no PR.
    • Retire "Not Started" (a blank field already means it) and "under product discussion" (folded into Waiting).
  • Moved the status changes to points in the work that happen anyway:
    • coding agents set In Progress when a session starts on a ticket, and Testing when validation begins;
    • PR-driven changes are automated from the Asana: line in the PR body, extending the release sync that already marks tickets Deployed.
  • Rolled it out on 2026-09-30:
    • Asana: added QA, renamed Testing to Validating, reordered the field to follow the workflow, disabled Not Started and under product discussion, and cleared the one open Current Sprint ticket still set to Not Started.
    • Wrote the lifecycle into the team's agent instructions and handbook, so every coding-agent session sets the status as it works, and turned the stale QA-ticket skill into a staging announcement for the rest of the team.
  • (Pending: a GitHub Action that resolves a ticket to In Staging when its PR merges into staging, ticket. PR Created is set by the agent through the asana skill.)

Result

Release sync (first result, 2026-09-29): the backfill run alone lifted the share of September's completed tickets sitting at a correct end state from 28% to 36% (73 → 92 of 258), and cut the ones with no status from 60% to 53%. It set 27 Current Sprint tickets Deployed; 16 of them had no status at all.

Lifecycle, early read (2026-10-05, five days in): 87% of tickets completed in October are at a correct end state (55 / 63), against 28% for September before the sync, and only 1 was left at a pre-merge state. 76% of shipped PRs now name their ticket, up from 17%. The month is not over; confirm at the 2026-10-28 check.

Lifecycle (pending): expected: most open tickets show a real status, finished tickets stop being left at an earlier state, and cycle time (In Progress → In Staging) becomes measurable for the first time. Measure against the pre-sync baseline below; the post-sync figures already include the sync's one-off backfill.

Metrics

Scope: Asana project "Current Sprint" (gid 1216663810328535), field "Task Progress". "Pre-sync" is the board as it stood before the release sync's backfill on 2026-09-29, reconstructed from each task's history (see How to measure). "Post-sync" is as measured on 2026-09-30.

Metric Pre-sync baseline Post-sync (2026-09-30) Target Latest (2026-10-05)
Open tasks with any Task Progress 4 / 86 (4.7%) same ≥ 80% 16 / 81 (20%). Under the new lifecycle blank means not started, so most open blanks are correct; see 2026-10-05 log
Tasks created since rollout with a status 60 / 77 (78%)
Completed tasks with no status (blank or Not Started), all time 243 / 401 (61%) 225 / 401 (56%) 236 / 479 (49%)
Completed tasks at a correct end state (Deployed, In Staging, Done), all time 94 / 401 (23%) 113 / 401 (28%) 180 / 479 (38%)
Completed tasks left at a pre-merge state (PR Created, Validating, In Progress), all time 64 / 401 (16%) 63 / 401 (16%) ≤ 5% 63 / 479 (13%)
Tasks completed in Sept 2026 with no status 154 / 258 (60%) 136 / 258 (53%) 140 / 273 (51%, late-closed Sept tasks added)
Tasks completed in Sept 2026 at a correct end state 73 / 258 (28%) 92 / 258 (36%) ≥ 85% (monthly) 104 / 273 (38%)
Tasks completed in Sept 2026 left at a pre-merge state 31 / 258 (12%) 30 / 258 (12%) ≤ 5% (monthly) 29 / 273 (11%)
Tasks completed in Oct 2026 (month to date) at a correct end state ≥ 85% (monthly) 55 / 63 (87%); blank 7 / 63 (11%), pre-merge 1 / 63 (2%)
Median cycle time, In Progress → In Staging not measurable (In Progress almost never set) measurable
PRs shipped in the prior month whose body names their ticket (Asana: line) 17% (from docs/asana-release-sync.md) most 130 / 171 (76%) carry an Asana: line and 104 / 171 (61%) name a ticket (30 releases, 2026-09-30 to 2026-10-05). Release of 2026-10-05: 28 / 28 and 20 / 28
Share of shipped tickets that needed human QA before merge not measurable (no QA state) measurable; falling as tickets get better defined
Release sync dry-run time, default run (scripts/asana-release-sync/run.sh) 177 s (7-day window, 38 releases, 217 PRs, 2026-09-30) 22 s same window, 9 s on the new 2-day default (2026-09-30) under 30 s
Options in use / defined 7 in use / 10 defined (Deferred never used; Waiting 2 and under product discussion 1, workspace-wide) 9 defined, all in use

Raw breakdown, all 487 Current Sprint tasks (401 completed, 86 open):

Task Progress Completed, pre-sync Completed, post-sync Open
blank 241 225 82
Deployed 43 70
PR Created 39 39 2
Done 23 22
In Staging 28 21
In Progress 13 12 1
Testing 12 12
Not Started 2 1

Sept 2026 (by completion month), post-sync: blank 136, Deployed 58, In Staging 21, PR Created 19, Done 13, In Progress 8, Testing 3. Pre-sync: blank 152, Deployed 31, In Staging 28, PR Created 19, Done 14, In Progress 9, Testing 3, Not Started 2.

The sync's 27 Current Sprint changes (all to Deployed, all on tasks completed in September) came from: blank 16, In Staging 7, Not Started 2, In Progress 1, Done 1. One of the In Staging priors is inferred: Asana's history does not expose that change's values, but the task was marked complete with a field change the same day.

How to measure

Run from the asana skill directory (~/.agents/skills/asana). It pages through every task on the project; do not use workspace search for this, since it caps at 100 results per query.

cd ~/.agents/skills/asana && python3 - <<'PY'
import subprocess, json, collections
P = "1216663810328535"; off = None; tasks = []
while True:
    a = ["./asana-cli", "GET", f"/projects/{P}/tasks", "-q", "limit=100", "-q",
         "opt_fields=completed,completed_at,created_at,custom_fields.name,custom_fields.display_value"]
    if off: a += ["-q", f"offset={off}"]
    j = json.loads(subprocess.check_output(a)); tasks += j["data"]
    off = (j.get("next_page") or {}).get("offset")
    if not off: break
def tp(t): return next(f["display_value"] for f in t["custom_fields"] if f["name"] == "Task Progress") or "(blank)"
by_state = collections.Counter((tp(t), t["completed"]) for t in tasks)
by_month = collections.defaultdict(collections.Counter)
for t in tasks:
    if t["completed"]: by_month[t["completed_at"][:7]][tp(t)] += 1
print("total", len(tasks))
for k, v in sorted(by_state.items(), key=lambda x: -x[1]): print(k, v)
for m in sorted(by_month): print(m, dict(by_month[m].most_common()))
PY

Definitions: a correct end state for a completed task is Deployed, In Staging or Done. A pre-merge state is PR Created, Testing or In Progress. For monthly rates, compare the latest full month against the Sept 2026 row. Cycle time needs each task's story history (GET /tasks/<gid>/stories, resource_subtype=enum_custom_field_changed); that measurement is not built yet.

Pre-sync reconstruction: for each task now at Deployed, fetch its stories and find the Task Progress change to Deployed made within 10 minutes of a comment starting Automated release sync:; its old_enum_value is the pre-sync state. Future measurements include the sync's effect on purpose (it is part of the system); only the one-off backfill needs separating out.

Log

  • 2026-09-30: Baseline taken on Current Sprint. Lifecycle agreed and rolled out (Asana field changed; workspace and handbook docs pushed to main; qwestly-agent skill fix in https://github.com/Qwestly/qwestly-agent/pull/348).
  • 2026-09-30: Team presentation drafted (slides _private/asana-ticket-lifecycle).
  • 2026-10-05: Re-measured (560 tasks: 479 completed, 81 open). October month to date: 87% of completed tickets at a correct end state, against 28% for September pre-sync, so the monthly target is met. Pre-merge leftovers in October: 1 / 63. PR Asana: line coverage 17% → 76% over the six days of releases. Ran the sync for that day's four-repo release: 19 tickets commented, 17 set Deployed. Open issues: 3 open tickets sit at Deployed but were never completed (Vela ×2, David ×1), and a 6-day dry run found 1 ticket the 2-day default window missed. The "open tasks with any status" metric no longer fits the lifecycle (blank = not started); replace it with "open tasks being worked on that carry a status" once In Progress is set reliably.
  • 2026-09-30: Release sync made fast enough to run after every release: GitHub reads fetched 8 at a time (writes still serial), default lookback 7 → 2 days. Same-window dry run 177 s → 22 s with an identical report; default run about 9 s. Workspace commit 71e91cb.