# 0.4.0 — Detached background runs

The headline shift in 0.4.0 is that runs become real background
subprocesses. You can launch a run, navigate away from the live-watch
screen, close the TUI entirely, then re-launch later and pick up
right where the team left off. The runner lives in its own OS
process; the TUI is just a viewer that tails the events.jsonl file
the subprocess is writing.

Several smaller pieces of UX work also rode along — most are
reachable directly from the project dashboard.

## Highlights

- **Detached background runs.** New `wonderland run-bg` subprocess
  command. The TUI spawns it via `Popen(start_new_session=True)`;
  closing the TUI no longer kills the run. Restart the TUI and the
  startup discovery scan picks it back up automatically.
- **App-level run registry.** Runs are owned by the app, not the
  live-watch screen. Pop the screen, the run keeps going. Re-mount
  any time and the screen replays the buffered events before tailing
  new ones.
- **Operator questions surface globally.** When an agent asks the
  operator a question (Alice's `question_to_operator` and friends),
  the modal pops regardless of which screen is mounted — dashboard,
  project library, anywhere. No more invisibly-blocked runs because
  the operator wandered off.
- **Dashboard runs column.** Runs are no longer a drill-down tab;
  they live in their own always-visible column to the left of the
  features section. Active runs render with a `▶ live` badge ahead
  of historical rows.
- **Enter on a finished run** opens it in the live-watch screen,
  replayed from disk. Same UI for live and finished runs.

## What changed

### Per-ticket queueing

(Note: needs the dashboard's "▶ Implement" button to be enabled
even when no feature is queued — that gate is in this slice too.)



- New ticket lifecycle (`PENDING / QUEUED / IN_PROGRESS / DONE /
  ABORTED`) parallel to feature lifecycle, persisted to
  `.wonderland/ticket-states.jsonl`. Operator can queue individual
  tickets from the dashboard via a `Queue ticket` button on the
  per-ticket detail view (mirrors the existing per-feature Queue
  button shape). Ticket-state badges (`▶ ⟳ ✓ ⚠`) appear as tree-
  node prefixes alongside the existing prune-mark `✗`.
- `_collect_per_item_items` gets a permissive override: when one
  or more tickets are explicitly queued for a feature, that
  feature passes the outer-pipeline `iterate_only_in_states`
  filter even if its own state isn't in scope, AND the inner
  ticket iteration scopes to the queued set instead of every
  ticket of the feature. The most common path this enables:
  ticket aborts on M7 budget, operator opens the dashboard, marks
  just that ticket as Queue, re-launches imp — only that ticket
  runs, not the whole feature.
- Backwards-compatible default: when no ticket has an explicit
  state record, the legacy behaviour holds (every ticket of every
  queued/in_progress feature iterates). The new filter only
  activates when there's at least one operator-driven queue mark.

### Ticket→feature mapping (prefix tolerance)

- `_ticket_to_feature_map` now tolerates three observed `**Sources:**`
  formats — bare slug, `feature: <slug>` (colon prefix), and
  `feature-<slug>` (dash prefix). Previously only the bare form
  matched, so tickets that came out of M3 with a prefixed source
  silently dropped from the parent map. They never reached the
  per-item iteration; implementation runs would skip tickets the
  operator could see right there on disk. Note: this fix does NOT
  address duplicate-slug tickets (which also reduce iteration
  counts) — those still need the dashboard's Mark Ticket / Delete
  Marked controls.

### Seed-scope tightening (Hatter sprawl fix)

- Per-ticket iterations (M6 / M7 / M8 in `tdd-implement`) no
  longer dump every feature + every contract_note into seed
  context — that's the substrate-level reason Hatter sprawled.
  Now: only the parent feature of the iteration's ticket loads,
  and only contract_notes from that feature's M5 thread load
  (parsed from the source thread_id `contract-negotiation-<feature>`).
  Disk-fallback notes from cross-run replay still pass through
  as graceful degradation since their synthesized thread_id
  drops the feature suffix. `adr` / `ruling` continue to load
  project-wide (project-scoped by design).

### Live-watch fixes

- Artifact Detail pane now renders the full artifact body in the
  scrolling pane, not just the kind/title/path header. Streaming
  ArtifactShipped events only carry the RunArtifact's path on
  disk; the pane reads from that path on highlight. Truncation
  cap is 50K chars (matches the dashboard's Artifacts tab) — the
  pane is meant for inspection, scrolling handles size.

- Meetings pane shows clean `M<N> — <name> (<idx>/<total>: <iteration>)`
  rows. The previous resolver in `HistoricalRunHandle._meeting_for`
  only recognised single-level `<base>-<slug>` thread ids and
  fell through to a synthetic meeting with `label=thread_id`,
  dumping the raw deeply-nested path
  (`pipe.<feature>.<inner>-<…>`) into the cell. Now:
    - Pipeline thread ids get their `pipe.<outer>.` prefix
      stripped before matching, the inner segment is looked up
      against the workflow's static meeting ids.
    - The dashboard passes the workflow name to the handle so
      the lookup is populated even for TUI runs (which don't
      write the run.log the legacy meeting parser depended on).
      Source order: `.wonderland/runs/<run_id>/status.json`
      first, then `project.last_workflow` fallback.
    - Iteration discriminator on the cell: live runs carry it on
      the event (`<feature> / <ticket>` for two-level
      pipelines); replay reconstructs it by parsing the
      thread_id structure against `meeting_lookup` and
      humanising both segments. Per-ticket implementation rows
      are distinct again.

### Live-watch + run lifecycle

- `LiveRunScreen` now accepts a `run_id` to attach to the App's
  active run via subscription instead of consuming a handle
  directly. The buffer replays past events; new events tail in.
- `App.launch_background_run` spawns `wonderland run-bg` as a
  detached subprocess and wraps a `SubprocessRunHandle` (tails
  `events.jsonl` + reads `status.json`) in the app's active-run
  slot.
- `App.abort_active_run` routes the abort signal correctly:
  `SIGTERM` to the subprocess pid for background runs, runner.abort
  for the (legacy) in-process path.
- TUI startup scans every registered project's
  `.wonderland/runs/<run_id>/` for `status=running` + alive pid
  and re-registers the first match. Notifies the operator:
  "Recovered background run <run_id>".

### Dashboard

- Runs column moves out of the bottom tab strip into a left column
  with list (top) + detail (below). Always visible.
- 3-column slim DataTable shape: Started / Cost / Outcome.
  Per-run breakdown lives in the detail pane.
- Synthetic `▶ live` row at index 0 when an active run is in flight;
  Enter on it pushes LiveRunScreen with the active run's `run_id`
  to reattach.
- Enter on a historical row opens it via `HistoricalRunHandle`,
  scoped to that run's `run_id` + time window so older runs render
  without later runs' artifacts mixed in.
- Drill-down tabs renumbered: 1=Artifacts, 2=Files, 3=Metrics.

### Operator UX

- AskUserModal: bigger (80% width / 110 max), suggested-answer
  buttons stacked vertically with full-width wrapping (no more
  40-char truncation).
- Quit-confirm modal now warns about in-flight runs:
  "A run is in flight: <run_id>. Quitting will abort it."
- One-run cap on the new-run launch path until per-run artifact
  tagging lands (concurrent runs against one project would
  interleave Dodo memory).

### Background-run substrate (under the hood)

- `observer/event_codec.py`: round-trippable JSONL serialization
  for all 13 RunEvent types. Forward-compat: unknown kinds raise
  `UnknownEventKind` rather than silently dropping events.
- `observer/subprocess.py`: `SubprocessRunHandle` tails
  `events.jsonl` with backoff, exposes `pid()` / `is_alive()` /
  `abort()`. Crash detection: `status=running` + dead pid → exit
  stream cleanly.
- `cli_run_bg.py`: the `wonderland run-bg` entry point. Writes
  events.jsonl, status.json, pid, log files into
  `.wonderland/runs/<run_id>/`. Handles SIGTERM/SIGINT for
  graceful abort.
- `tui/active_run.py`: `ActiveRun` data structure with event
  buffer + subscriber list. App-owned; survives screen pop.

### Smoke workflow for AskUserModal

- New `smoke-ask-user` workflow + paired directive preset. Asks
  Alice to fire one `question_to_operator` with four longish
  options (~150 chars each), then quiesce. Use it to validate
  modal layout changes without running a full design or
  implementation pass. ~$0.05 / ~30s per run.

## Test plan (afk-recovery checklist)

When you're back at the rig, run through these to validate the
batch end-to-end:

1. **Smoke the AskUserModal** — `wonderland-tui`, launch the
   `smoke-ask-user` directive against a sandbox project. Confirm
   modal renders correctly: bigger width, 4 options stacked
   vertically, each option wraps cleanly without the
   `40-char ellipsis`.
2. **Background-run primitive** — launch `smoke-ask-user` from
   the new-run screen. Confirm:
   - Live-watch screen shows events streaming in.
   - `Esc` to dashboard → run keeps going (subprocess survives
     screen pop). Dashboard runs column shows `▶ live` row.
   - Enter on `▶ live` → reattaches; you see continuation, not a
     fresh replay.
3. **Detach + reattach across TUI restart** — launch a longer
   run (regular `tdd-design` smoke, or wait for `smoke-ask-user`
   to be mid-flight). Quit the TUI (you should see the warning
   "A run is in flight"). Re-launch `wonderland-tui`. Confirm
   you get the "Recovered background run <id>" notify and the
   dashboard's `▶ live` row is present.
4. **Abort path** — from the live-watch screen, hit the abort
   button (or `Ctrl+C` keybind). Confirm the subprocess receives
   SIGTERM and the status flips to `aborted`. Check
   `.wonderland/runs/<run_id>/log` for any stderr noise.
5. **Operator question global pop** — during the `smoke-ask-user`
   run, navigate to the dashboard or project library while the
   question is pending. Confirm the modal pops on top of whatever
   screen is mounted, and answering it closes the modal cleanly.
6. **Finished run replay** — after a run completes, hit Enter on
   it from the dashboard runs column. Confirm LiveRunScreen
   opens with that run's events (artifacts/utterances scoped to
   the run, not project-cumulative).
7. **Dashboard runs column** — confirm the column renders with
   the slim 3-column shape, that highlighting a row updates the
   detail pane below, and that the active row sits above
   historical rows.
8. **Quit guard** — quit while a run is in flight. Confirm the
   warning is loud + that confirming actually kills the run.

## Known follow-ups (deliberately out of scope for 0.4.0)

- **Pause/resume across processes.** SubprocessRunHandle has no
  pause primitive yet — the `p` keybind on a reattached
  background run silently no-ops. Either a control file the
  runner polls, or a tiny IPC channel.
- **Per-run artifact tagging.** Dodo's episodic memory and the
  artifact directories are still project-cumulative. The
  one-at-a-time cap exists because concurrent runs would
  interleave memory + clobber artifacts. Tagging by `run_id`
  makes concurrent runs safe.
- **Stale background-run cleanup.** Discovery skips
  `status=running + pid dead` entries but doesn't repair the
  status file. A "mark stale runs as error" pass on startup or
  dashboard refresh would close that loop.
- **Auto-sentinel on global question handler.** The screen-level
  `T`-keybind cycle (auto-dismiss timeouts) doesn't apply when
  the App's global handler fires. Move the cycle to App level
  when someone misses it.
- **Notifications / remote-control hooks.** With detached
  processes, the substrate's there for the TUI (or another
  consumer) to wake on run events without having to be
  open at all. Filed for follow-up.
