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>
16 KiB
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) 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:
- Vista divisa — a read-only side panel showing the session's stored content.
- Rinomina — rename a session.
- Sposta nel gruppo — organize sessions into groups.
- Archivia — archive a session out of the active list (read-only retention).
- 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): "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)
| 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)
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"ORarchived == 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
archivedfield 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 resolutionsessions_root / session_idand 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
groupstring 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 showsnameinstead of the question when present.
Data model — manifest changes
In SessionManifest add two fields
(name already exists):
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, with corresponding helpers in
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>→ setmanifest.name.tht session set-group <id> --group <name>→ setmanifest.group(empty string clears it back toNone).tht session archive <id>/tht session unarchive <id>→ fliparchived. (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:Document set: original question (from manifest),[ { "phase": "F3", "key": "revised_question", "title": "Domanda rivista", "format": "markdown", "content": "..." }, ... ]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 thesessionsroot lives in the workspace YAML (resolved bythtconfig, unknown to Node) — so reading goes throughtht.session list --json→ each row additionally carriesarchived,group,name(in addition to the existingid/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, 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 includearchived/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)
- Extend
SessionSummarywitharchived: 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)
- Add
renameSession(id, name),setSessionGroup(id, group),archiveSession(id),unarchiveSession(id),deleteSession(id),getSessionDocuments(id).
NavSessions (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 distinctgroupvalues, plus a "Senza gruppo" section forgroup == 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 byonOpenPanel, 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,"schema-linking"→ SchemaLinkingViewer,"markdown"/"text"→ MarkdownView. - Decisioni: the
decisionsdocument rendered as a collapsible list at the bottom.
AppShell (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:
- Backend sends the wrong prompt.
PiProcessManager.spawnFor(pi-process-manager.ts) always sends/nuova-domanda "kickoff", andresumecalls the same path — so a "resume" kicks off a new question.spawnFormust take anew/resumemode: new →/nuova-domanda, resume →/riprendi-sessione <id>(the prompt riprendi-sessione.md). - 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 (or confirm the model reliably bootstraps fromtht 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. - 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.pyhelpers (set-name/group, archive/unarchive, delete) and thesession documents --jsonshape; manifest round-trips with the new fields;session list --jsonincludes them. - Backend (vitest): each new route maps to the right
thtinvocation (injectedThtRunner/spawn double); resume guard returns 409 for finalized/archived;spawnForsends/riprendi-sessionein resume mode and/nuova-domandain 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 viatsvector) or when "reuse a semantically similar past session" becomes a goal (→ add avectorcolumn + 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.