Files
ThothII/docs/superpowers/specs/2026-06-29-session-management-design.md
T
marcopanandClaude Opus 4.8 4364bdd4a1 docs(spec): frame cold-start resume correctness as a prerequisite
Add the SKILL.md design contract (persisted state is the truth) and a
dedicated Resume correctness section: backend new/resume prompt mode,
missing cold-start procedure in the skill, end-to-end verification gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 11:39:56 +02:00

271 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Session Management — Design
**Date:** 2026-06-29
**Status:** Approved (design), pending implementation plan
**Layers:** frontend, backend (Fastify), harness (`tht` CLI)
## Problem
The session rail ([NavSessions.tsx](../../../frontend/src/shell/NavSessions.tsx)) is
today a flat list whose only interaction is **click = resume** (spawns a Pi process).
There is no way to read a past session, rename it, organize it, archive it, or delete
it. Inspired by Claude's desktop session context menu, we want a complete management
surface with five capabilities:
1. **Vista divisa** — a read-only side panel showing the session's stored content.
2. **Rinomina** — rename a session.
3. **Sposta nel gruppo** — organize sessions into groups.
4. **Archivia** — archive a session out of the active list (read-only retention).
5. **Elimina** — permanently delete a session.
## Key architectural insight (drives the whole design)
The workflow is **phase-based**. Each phase persists its own document into the session
directory; advancing to the next phase does **not** require replaying the prior
conversation. Therefore **the phase documents ARE the persistence** — we do **not**
need to store the verbatim chat.
This is the explicit design contract of the orchestrator
([SKILL.md](../../../harness/.pi/skills/tht-sessione/SKILL.md)): *"the persisted state
(ledger `review_decisions.jsonl` + artifacts) is the truth — what is not recorded did not
happen."* The documents are not meant to *contain* the chat; they are meant to make the
chat **reconstructable / unnecessary** — the documents are the *state*, the skill is the
*procedure*, and together they let a fresh model continue the task. Whether that holds for
a cold-start resume is a **verifiable prerequisite**, treated explicitly below.
This was the crux decision in brainstorming. It means:
- The "vista divisa" is a **read-only document viewer** driven by what's on disk, not a
chat replay. No new transcript-persistence layer, no correlation of Pi's internal
JSONL logs.
- "Resuming an interrupted session" = the normal chat flow re-entering at the **last
incomplete phase**; the earlier phases' documents are the context.
### What each phase persists (from [workflow.yaml](../../../harness/workflow.yaml))
| Phase | Document on disk |
|-------|------------------|
| (manifest, always) | `session_manifest.yaml` — original question, name, status, author, timestamps |
| F3 riscrittura | `question.md` — revised question + assumptions |
| F4 schema_linking | `schema_linking.json` |
| F6 cte | `cte_plan.json`, `cte_tests.json` |
| F7 sql_finale | `sql_final.sql` |
| finalize | `validation_report.md`, `evidence.json` |
| (decisions, always) | `review_decisions.jsonl` — decision ledger |
`tht session show --json` already returns the manifest **plus** the computed `phase`
(current phase, folded from the ledger by [phase.py](../../../harness/tht/phase.py))
and `has_schema_linking`.
## Decisions (from brainstorming)
- **Read panel content (option C):** a status header (`Fase N di 8 · <nome>` /
`Completata` / `Archiviata`) + document cards **in phase order** (only those that
exist) + a collapsible **Decisioni** block. No verbatim chat.
- **Resume rule:** `status == "finalized"` OR `archived == true` → **read-only**, never
resumable. Any other state (`open`, `closed`) → **resumable** into the last incomplete
phase.
- **Row click = open the read-only panel** (it no longer resumes). **Resume** is an
explicit **"Riprendi"** button shown in the panel header only when the session is
resumable.
- **Archive = filesystem flag.** A boolean `archived` field on the manifest. The active
rail filters out archived sessions; a separate **"Archivio"** view lists them and opens
the same read-only panel. (A flag, not a directory move, so id→path resolution
`sessions_root / session_id` and resume/show keep working unchanged.)
- **Delete = hard delete** of the session directory, behind a **confirmation dialog**.
Available from both the active list and the archive. No trash/soft-delete — the archive
is the "soft" tier.
- **Groups = manifest field (model A).** A nullable `group` string on the manifest. The
rail derives the group list from distinct values. "Sposta nel gruppo ›" lists existing
groups + "Nuovo gruppo…" (type a name). No separate registry, no colors/ordering, no
persistent empty groups.
- **Rename = manifest `name`** (field already exists). The rail shows `name` instead of
the question when present.
## Data model — manifest changes
In [`SessionManifest`](../../../harness/tht/session/models.py) add two fields
(`name` already exists):
```python
archived: bool = False
group: str | None = None
```
Both default such that existing manifests load unchanged. They are written by the new
mutating commands below and surfaced by `session list`/`session show`.
## Harness layer (`tht` CLI)
New/changed subcommands in
[session_cmd.py](../../../harness/tht/cli/session_cmd.py), with corresponding helpers in
[store.py](../../../harness/tht/session/store.py). All mutate the manifest via the
existing `touch_manifest`/`to_yaml` pattern and update `updated_at/updated_by`.
- `tht session set-name <id> --name <name>` → set `manifest.name`.
- `tht session set-group <id> --group <name>` → set `manifest.group`
(empty string clears it back to `None`).
- `tht session archive <id>` / `tht session unarchive <id>` → flip `archived`.
(Unarchive is included so an accidental archive is recoverable; it does **not** change
resumability — a finalized session stays read-only.)
- `tht session delete <id>` → remove the session directory (`shutil.rmtree`). Idempotent
error if absent.
- `tht session documents <id> --json` → **new read command** returning the ordered,
available documents for the panel:
```json
[ { "phase": "F3", "key": "revised_question", "title": "Domanda rivista",
"format": "markdown", "content": "..." }, ... ]
```
Document set: original question (from manifest), `question.md`, `schema_linking.json`,
`sql_final.sql`, `validation_report.md`, and the decision ledger. Only documents that
exist on disk are returned. The F6 CTE artifacts (`cte_plan.json`, `cte_tests.json`)
are **intentionally excluded** from the v1 panel as intermediate workflow state, not
reviewer-facing deliverables; add later if needed. The backend cannot read these files directly because the
`sessions` root lives in the **workspace** YAML (resolved by `tht` config, unknown to
Node) — so reading goes through `tht`.
- `session list --json` → each row additionally carries `archived`, `group`, `name`
(in addition to the existing `id/status/question/summary/created_at/updated_at/author`).
The active vs archive split and grouping are done in the frontend from these fields.
## Backend layer (Fastify)
New routes in [sessions.ts](../../../backend/src/routes/sessions.ts), each delegating to a
new `ThtRunner` method that shells the matching `tht` subcommand:
- `POST /sessions/:id/rename` `{ name }` → `tht session set-name`.
- `POST /sessions/:id/group` `{ group }` → `tht session set-group` (`group: ""` clears).
- `POST /sessions/:id/archive` / `POST /sessions/:id/unarchive`.
- `DELETE /sessions/:id` → `tht session delete`. If a Pi runtime is live for that id,
tear it down first (`mgr.teardown`).
- `GET /sessions/:id/documents` → `tht session documents --json`.
- `GET /sessions` → unchanged route; rows now include `archived/group/name`.
**Resume guard.** `POST /sessions/:id/resume` must refuse when the session is read-only.
Before spawning, read the manifest (`tht.sessionShow`); if `status == "finalized"` or
`archived`, return `409` with a clear message and do **not** spawn Pi.
**Resume must rebuild phase context.** This is the backend half of the resume-correctness
prerequisite: `spawnFor` gains a `new`/`resume` mode (new → `/nuova-domanda`, resume →
`/riprendi-sessione <id>`). See the dedicated **Resume correctness** section below for the
full picture (backend prompt, skill cold-start procedure, end-to-end verification).
## Frontend layer (React + base-ui + Tailwind + React Query)
### Types ([types.ts](../../../frontend/src/api/types.ts))
- Extend `SessionSummary` with `archived: boolean`, `group: string | null`,
`name: string | null`.
- Add `SessionDocument { phase: string; key: string; title: string; format: "markdown" | "sql" | "schema-linking" | "decisions" | "text"; content: string }`.
### API client ([sessions.ts](../../../frontend/src/api/sessions.ts))
- Add `renameSession(id, name)`, `setSessionGroup(id, group)`, `archiveSession(id)`,
`unarchiveSession(id)`, `deleteSession(id)`, `getSessionDocuments(id)`.
### NavSessions ([NavSessions.tsx](../../../frontend/src/shell/NavSessions.tsx))
- **Row click → open the read-only panel** (via a new `onOpenPanel(id)` prop), no longer
resumes.
- Render the active list (filter `!archived`) grouped under **collapsible group headers**
derived from distinct `group` values, plus a **"Senza gruppo"** section for `group == null`.
- Each row gets a **kebab (⋮) menu on hover** (base-ui Menu) mirroring the reference
image:
- **Vista divisa** → open the read-only panel
- **Rinomina** → rename dialog (sets `name`)
- **Sposta nel gruppo ›** → submenu of existing groups + "Nuovo gruppo…"
- **Archivia** → `archiveSession`
- **Elimina** (red) → confirmation dialog → `deleteSession`
- After each mutation, invalidate the `["sessions"]` query.
### Archive view
- An **"Archivio"** entry at the bottom of the rail. Selecting it shows archived sessions
(filter `archived`), each opening the **same** read-only panel. Archived rows offer
**Vista divisa**, **Ripristina** (unarchive), and **Elimina** — no resume.
### SessionDocumentsPanel (new, left drawer)
- New `frontend/src/shell/SessionDocumentsPanel.tsx`. A left column/drawer, opened by
`onOpenPanel`, closed with ✕.
- **Header:** name/question, status chip (`Fase N di 8 · <nome>` / `Completata` /
`Archiviata`), author, created/updated. A **"Riprendi"** button rendered only when
resumable; it triggers the existing resume flow and focuses the center chat.
- **Body:** one card per `SessionDocument`, in phase order, rendered by format reusing
existing viewers — `format: "sql"` → [SqlViewer](../../../frontend/src/viewers/SqlViewer.tsx),
`"schema-linking"` → [SchemaLinkingViewer](../../../frontend/src/viewers/SchemaLinkingViewer.tsx),
`"markdown"`/`"text"` → [MarkdownView](../../../frontend/src/viewers/MarkdownView.tsx).
- **Decisioni:** the `decisions` document rendered as a collapsible list at the bottom.
### AppShell ([AppShell.tsx](../../../frontend/src/shell/AppShell.tsx))
- Add the **left panel column** to the existing layout, giving three columns:
`[ documents panel (left) | chat (center) | sessions rail (right) ]`.
- Track two distinct ids: the **active chat session** (drives `useSessionStream` + the
center) and the **panel session** (read-only, drives the left drawer). Opening the panel
does not touch the active chat; "Riprendi" promotes the panel session to the active one.
## UX summary
- **Layout:** three columns, panel on the left as requested, rail stays on the right.
- **Context menu:** kebab ⋮ on hover, items as above; "Elimina" in red with confirm.
- **Rail organization:** collapsible group headers + "Senza gruppo"; "Archivio" as a
separate section, never mixed into the active list.
## Resume correctness — verifiable prerequisite
The whole "no chat persistence" premise rests on a fresh Pi process being able to
**re-enter the workflow at the last incomplete phase using only the persisted state**.
That is the design contract quoted above (ledger + artifacts are the truth; each phase's
prerequisites are prior artifacts; rollback `/torna N`, discipline 11, already resumes
"reviewing the existing artifacts" without replaying chat). The premise is sound — but the
**cold-start resume path** (new process, zero conversation) is today thin and unproven.
This feature treats resume correctness as a prerequisite and must close three gaps:
1. **Backend sends the wrong prompt.** `PiProcessManager.spawnFor`
([pi-process-manager.ts](../../../backend/src/pi/pi-process-manager.ts)) always sends
`/nuova-domanda "kickoff"`, and `resume` calls the same path — so a "resume" kicks off a
*new* question. `spawnFor` must take a `new`/`resume` mode: new → `/nuova-domanda`,
resume → `/riprendi-sessione <id>` (the prompt
[riprendi-sessione.md](../../../harness/.pi/prompts/riprendi-sessione.md)).
2. **No cold-start procedure in the skill.** The phases assume "you must already be in
Phase 1"; there is no "Phase 0 / Resume" step telling a fresh process to read
`tht session show <id>`, take the current phase N, load that phase's artifacts, and
resume its procedure. Add a short **Resume** section to
[SKILL.md](../../../harness/.pi/skills/tht-sessione/SKILL.md) (or confirm the model
reliably bootstraps from `tht session show` + artifact reads). The one-line resume
prompt is not sufficient on its own — intra-session rollback works only because the
conversation is still live; cold start has nothing to lean on.
3. **End-to-end verification.** A test that creates a session, drives it partway (e.g. into
F4), tears down the Pi process, resumes in a fresh process, and asserts it lands on the
**correct phase** with the prior artifacts available and presents the next gate — not a
new-question kickoff.
Until (1)–(3) pass, the phase-document model is a design assumption, not a proven fact.
The read panel, rename, group, archive, and delete work regardless of resume; but the
**"Riprendi"** action is only trustworthy once resume correctness is verified, so it must
not ship as "working" before this gate is green.
## Testing approach (TDD per repo convention)
- **Harness (pytest):** new `store.py` helpers (set-name/group, archive/unarchive,
delete) and the `session documents --json` shape; manifest round-trips with the new
fields; `session list --json` includes them.
- **Backend (vitest):** each new route maps to the right `tht` invocation (injected
`ThtRunner`/spawn double); resume guard returns 409 for finalized/archived; `spawnFor`
sends `/riprendi-sessione` in resume mode and `/nuova-domanda` in new mode.
- **Frontend (vitest + RTL + MSW):** kebab menu actions call the right endpoints and
invalidate the query; grouping/archived filtering renders correctly; the panel renders
documents via the right viewers and shows/hides "Riprendi" by resumability;
delete-confirm flow.
- **Resume correctness (end-to-end):** the verification described in the *Resume
correctness* section — partway session → teardown → fresh-process resume → lands on the
correct phase with prior artifacts, not a new-question kickoff. This gate must be green
before "Riprendi" is considered working.
## Out of scope / deferred (with triggers)
- **Application database / pgvector.** Not introduced. The only DB is the client's
read-only DWH; an app DB is new infrastructure not justified by store/browse/view/
no-resume. Revisit when the archive must outlive the workspace filesystem (→ app
Postgres + `archived_sessions`, full-text via `tsvector`) or when "reuse a semantically
similar past session" becomes a goal (→ add a `vector` column + pgvector). Both are
non-breaking additions later.
- **Keyboard shortcuts** (R/A/D/F in the reference image) — polish, addable later.
- **Group colors, ordering, persistent empty groups** — the model-B registry, only if
needed.
- **Verbatim chat persistence** — explicitly unnecessary given the phase-document model.