---
name: b8-command-runner-pty-framing
description: Cadrage hexagonal figé du lot B8 : runner concret sur le port BackgroundTaskRunner, fermeture de la boucle sink en composition root, contrat de complétion, commandes cancel/retry.
metadata:
  type: reference
---
# B8 — Runner de commandes couplé PTY (FIGÉ Architecture, 2026-07-03)

Base `feature/background-tasks-first-class`. Fait suite à [[background-tasks-first-class-design]] et [[b7-wiring-anchor-state-rs]].

## Trou constaté
`BackgroundCompletionSink::new`/`start_from_runner` n'existent QUE dans les tests. En prod `state.rs`, `background_ready_tx` n'est alimenté que par le reconcile boot ; aucun `impl BackgroundTaskRunner` concret. `LocalProcessSpawner.run` = one-shot Output, non couplé.

## Décisions
- **Le spawner ne crée pas la task.** Concepts owner/project/wake_policy = application. On implémente le port FIGÉ `BackgroundTaskRunner` (domain/ports.rs:1349) via nouvel adapter infra `CommandBackgroundRunner` composant `Arc<dyn PtyPort>` (résolu via RemoteHost → Liskov SSH/WSL), PAS un PortablePtyAdapter en dur.
- Application = use case `SpawnBackgroundCommand` : alloue TaskId, store.create(Queued)→Running, runner.spawn(BackgroundTaskSpec). Le spec (ports.rs:207) porte déjà task_id/project_id/owner_agent_id/kind/wake_policy/command:Option<SpawnSpec>/deadline.
- Le runner n'écrit NI store NI inbox : il émet 1 `BackgroundTaskCompletion` sur subscribe_completions(). Le sink (single-writer) fait store.save PUIS ready_tx.send (persist-avant-signal).
- Composition root ferme la boucle : construire runner + `BackgroundCompletionSink::new(store_port, background_ready_tx)` + `sink.start_from_runner(runner)` sur `tauri::async_runtime::spawn` (pas de tokio ambiant). Ancre state.rs ~1540-1660.
- Frontière : seuls SpawnSpec/BackgroundTaskSpec/BackgroundTaskCompletion (domaine) franchissent ; portable-pty reste en infra. Rendu xterm = tee du même spawn vers PtyBridge, orthogonal au tracking.

## Contrat complétion
BackgroundTaskResult Success/Failure { finished_at_ms, exit_code:Some, summary, stdout_tail, stderr_tail bornés (ring UTF-8-safe, cap IDEA_BG_TAIL_BYTES ~8-16KiB) }. Cancel→runner kill→Cancelled{reason}, pas de wake succès. Invariants tenus par le sink (dédup task_id, IgnoredAlreadyTerminal, mark_completion_delivered) + pont enqueue_message (jamais busy) + wake si idle. Final reste terminal-de-tour, la complétion est un InboxItem::BackgroundCompletion.

## Commandes Tauri (dépend B8)
cancel_background_task({taskId}); retry_background_task({taskId})→BackgroundTaskDto (NOUVEAU task_id, jamais réutilisé); list_background_tasks({projectId,agentId?}). DTO camelCase {taskId,ownerAgentId,projectId,kind,state,exitCode?,summary?,stdoutTail?,stderrTail?,createdAtMs,updatedAtMs}.

## Sous-tâches DevBackend (ordre)
1 util tail borné (infra). 2 CommandBackgroundRunner (infra/background_task.rs + lib.rs). 3 use cases SpawnBackgroundCommand/Cancel/Retry (application). 4 fermer boucle sink en composition root (app-tauri/state.rs). 5 handlers+DTO (commands.rs, events.rs). 6 tee PTY live (pty.rs). 7 tests (réutiliser tests/background_completion_sink.rs).