# Async Task UI

> Async Task UI

- Skill: `poloplay0114/async-task-ui` (Agent Skill)
- Install (CLI): `npx skillmds@latest add poloplay0114/async-task-ui`
- Raw SKILL.md: https://api.skillmd.com/api/skills/poloplay0114/async-task-ui/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Productivity
- Author: poloplay0114 (https://skillmd.com/u/poloplay0114)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/poloplay0114/async-task-ui

---


# Async Task UI

## When to use this

Any time a "start" button appears in the interface and the work takes more than a few seconds: report
or document generation, batch imports, long-running queries, AI generation, file conversion. The
physics here is fixed: **HTTP is stateless, the frontend is mortal (reloads, closed tabs, dropped
connections), and users click things** — and the task has to outlive all three. The industry has a
highly consistent standard skeleton for this (submit → return a task ID immediately → run in the
background → poll a status endpoint → terminal state → claim the result); this skill is that skeleton
plus the iron rules bought with real incidents. The failure modes are extremely fixed, and so are the
standard fixes — nothing here needs inventing, it needs not deviating from.

---

## Core rules

### Rule 1: The state machine comes before the interface — settle life and death, then draw

Before writing any task UI component, settle the task state machine. The minimum set is
`queued → running → succeeded / failed / cancelled (terminal)`, plus an orthogonal `delivered` flag.
- **Write the transition table into the code as a table** (which state can reach which), and forbid
  scattered `if`s; give the table its own tests — an illegal transition must bark.
- Progress percentages and time estimates are decoration; **the state is the skeleton**. Only once you
  have honest states do you get to have a progress bar (if you cannot produce an honest percentage,
  do not produce one — a state label is more trustworthy than a fake percentage).
- Writing the interface before settling the state machine means every component invents its own notion
  of a task's life and death, and from then on every bug is "two places disagree about task state."

### Rule 2: Terminal states are irreversible — completed tasks go to an inbox and never come back to life

Terminal states (succeeded/failed/cancelled) are one-way: **no automatic mechanism (reconnect, polling,
recovery) may ever reopen a terminal task as a working view.**
- A task that completed but was not claimed (succeeded and not delivered) → goes to the
  **inbox / task center**: listed passively, opened only when the user clicks it; it never seizes a
  tab or the layout on its own.
- Leftover historical tasks (from days ago, from testing) all go to the inbox; "on load, open every
  task you can find as a tab" is this skill's most classic crime scene (see below).
- The inbox also solves the "the user was not present when the task finished" delivery problem — the
  result has somewhere to live, and you are not betting that their window is still open.

### Rule 3: Views are separate from tasks — the server is the single source of truth, the frontend is only a telescope

A task's survival **must never depend on the survival of any window, tab, or frontend object**:
- After submission the task belongs to the server; the frontend holds a task_id and polls the status
  endpoint — close the page, reload, drop the connection, and the task keeps running.
- **A reload is the telescope refocusing**: on load, query for running and queued tasks and reattach
  the views; it is not the task being reborn, and certainly not the task dying.
- The reconnect mechanism recognizes **non-terminal** states only (Rule 2); and "reattach" means
  updating an existing view or reattaching to an existing container — **never open a new view just
  because polling found something**. A view may be born from exactly two legitimate causes: a user
  action, or reattaching on load to a task that is genuinely running.
- With multiple views (multiple tabs), each view holds its own state container (a per-tab context);
  global singletons are forbidden — global state swapped across an await will always produce a
  re-entrancy collision.

### Rule 4: Idempotency is an obligation of submission — double-clicks, retries, and reloads must never spawn a second job

Background work is inherently triggered more than once (double-clicks, network retries, pressing the
button again after a reload) — **the same logical job may run any number of times and must still
count once**:
- Frontend: disable the button on submit / debounce it (the first line of defense, against fast fingers).
- Server: an idempotency key (a content key or a request key) — the same key arriving twice returns the
  same task_id and does not open a new task (the second line, against everything the frontend cannot
  catch). You need both; frontend-only is none.
- Cancellation is idempotent too: hammering cancel is harmless; cancelling mid-run goes through a
  `cancel_requested` intermediate state (the UI shows "cancelling…") and the worker converges to
  cancelled at a checkpoint — it neither pretends to die instantly nor invites frantic clicking.

### Rule 5: Make concurrency semantics explicit — never make the user guess; blankness is itself a defect

Can I start another one? What position am I in? What is the limit? — the answers must be **written on
the interface**:
- If there is a queue, show the position ("queued, N ahead of you"), not a fake "running 0%."
- If there is a limit, **block at the limit and say so** ("limit of N reached, close one first"),
  rather than failing silently.
- A user's core anxiety about background work is control: is reloading safe, can I run these in
  parallel, has the system actually updated? If the UI does not say, the user starts experimenting,
  and experimenting triggers exactly what Rule 4 exists to block.
- A newly opened workspace must be immediately usable or must explain why it is not; **rendering a
  blank and letting the user guess is a defect**, not "a feature that isn't finished yet."

### Rule 6: A dirty environment is the default — all green in a clean environment ≠ all green in a real one

Test fixtures for a task UI must have **two tracks**: a clean environment and a dirty one (preload N
historical terminal tasks plus 1 running task, then load).
- Real users' environments always carry history: yesterday's tasks, last week's leftovers, orphans from
  a half-finished test. Tests that only start from a clean environment have zero coverage of loading,
  the single most everyday action there is.
- The minimum assertion set for the dirty track: after loading, exactly the genuinely-running task is
  reattached; 0 terminal tasks revive automatically; the inbox lists every unclaimed task.
- For the pre-delivery self-walk, the environment is **an equivalent copy of the real user's
  environment** (all leftovers included); walking a clean environment is not walking it at all.

---

## Case files from this project (supporting evidence, not required for the general rules)

- **Historical tasks coming back from the dead (Rules 2/3/6 all at once)**: one reporting system added
  a reconnect mechanism for "recover after a reload," but it never filtered on task state and the
  polling loop opened a new tab for every task it found — the user uploaded a single file and batches
  completed days earlier let themselves in, one tab at a time, until the bar was full. Three
  violations: terminal states resurrected (Rule 2), polling opening new views (Rule 3), and every
  journey test starting from a clean environment so everything was green (Rule 6). The fix: add
  terminal states to the state machine + route completed-but-unclaimed work to an inbox + reconnect
  recognizes non-terminal states only + dual-track dirty-environment tests.
- **Finished but nowhere to be found (Rules 1/3)**: a multi-item batch wrote its results into the
  multi-item results container, while the delivery window read the single-item `state['result']` → the
  progress bar showed a checkmark and opening the window gave a 404. The root cause was the absence of
  a unified state machine: the single-item and multi-item paths each invented their own shape for
  "finished." The 5 finished artifacts were sitting perfectly safely on disk — the address was wrong,
  not the building; but to the user "I cannot get it" means "it was never produced," and the
  severity is counted that way.
- **Global state trampling itself (Rule 3)**: multiple tabs shared module-level globals and a single
  DOM container, so switching tabs clobbered state and running views vanished; a timer swapping globals
  across an await is re-entrancy. The fix: thread a per-tab context through everywhere, binding state
  to the tab rather than to the module.
- **Double-click protection (Rule 4)**: pressing "start" 3 times produced 3 tasks. The fix: frontend
  debounce + server-side idempotency, so 3 submissions execute exactly 1 time, with tests pinning it.
- **Sister skills**: the verification approach for state machines and terminal states is the same as
  verification-discipline's (independent expected values, breaking tests that bark); for when to check
  external practice before starting, see engineering-economy Rule 1; for delivery stations and
  journey-level acceptance, see ui-station-delivery; the data-layer counterpart of dual-track dirty
  environments and cache resurrection is in cache-discipline (a cache seeded by a broken reader is
  structurally invalidated).

