Handoff-based continuation (replacing S3 conversation restore)¶
Date: 2026-07-04 Status: design (approved-by-delegation; user granted full autonomy) Scope: tatara-chat, tatara-cli, tatara-claude-code-wrapper, tatara-agent-skills, tatara-operator Origin: token-conservation component 3 (handoff-replaces-S3), reshaped by the user's directive that stale-session S3 restore cost is nonlinear and handoffs are first-class queryable tatara-chat objects.
Problem¶
A fresh agent pod today restores its predecessor's FULL conversation transcript from S3 (convstore.Restore + claude --resume), so continuation re-bills the entire prior conversation as cold input - a nonlinear cost that dwarfs the value of the carried context. The intent is stateless pods that carry only a COMPACT handoff ("where I left off + next steps"), stored where the platform's own agents can query and groom it.
Goals¶
- Replace S3 conversation restore with compact, queryable handoffs stored in tatara-chat (a new first-class entity), keyed by the existing stable-per-issue
CONVERSATION_OBJECT_KEY. - Agents write a handoff at end-of-work and read it at start, via chat MCP tools driven by the
/handoffskill. - The
refineagent grooms handoffs (delete stale/done, keep fresh). - The
brainstormagent prioritizes continuing open handoffs before fresh ideas. refineandbrainstormrun under ONE schedule (refine, then brainstorm).
Non-goals¶
- Conversation-level fidelity. Handoffs are summaries, not replayable transcripts; a fresh pod does not reconstruct the prior turn history.
- A generic chat entity store. Handoffs are a purpose-built table matching the chat house style (typed columns, hand-written SQL, no JSONB).
- Per-user RBAC on handoffs (chat auth is per-token identity, no RBAC; inherited unchanged).
- Keeping S3 conversation plumbing "just in case" - it is removed (the cost is the whole point).
Design decisions¶
- Storage = tatara-chat (user decision). A new
chat_handoffstable, not the rooms/messages tables. - Handoff key = the wrapper's existing
CONVERSATION_OBJECT_KEY("stable per issue") - no new task identifier minted; the operator keeps passing it. - One current handoff per key (UNIQUE + upsert). "Open" = exists; refine deletes when done/stale. No status column (YAGNI).
- Continuation is agent-driven: the wrapper surfaces the key in a first-turn preamble; the
/handoffskill +get_handofftool do the read. The wrapper holds no chat client. - Cron-merge reuses the EXISTING refine pre-scan barrier, re-scoped to the brainstorm tick - no new completion-hook machinery.
The loop¶
work Task boots FRESH (no S3 restore) -> wrapper prepends a handoff preamble
(the key) to the first goal -> agent (via /handoff skill) calls get_handoff
-> resumes from the compact handoff -> does work -> write_handoff at end.
brainstorm cron tick -> refine Task created + barrier holds -> refine grooms
handoffs (list_handoffs; delete stale/done) + issues -> refine terminal
-> barrier releases -> brainstorm Task -> list_handoffs, prioritize
continuing open handoffs, THEN fresh ideas.
Component 1: tatara-chat handoff store (foundation)¶
A new vertical slice (chat has no generic store):
- Migration
internal/chat/migrations/0005_handoffs.sql, wired atinternal/chat/migrate.go:24:CREATE TABLE chat_handoffs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), handoff_key TEXT NOT NULL UNIQUE, project TEXT NOT NULL, repo TEXT, kind TEXT, created_by TEXT NOT NULL, body TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX chat_handoffs_project_idx ON chat_handoffs (project, repo, updated_at DESC); - Model
Handoffstruct ininternal/chat/chat.go. - Store methods in
internal/chat/store.go:UpsertHandoff(INSERT ... ON CONFLICT (handoff_key) DO UPDATE, latest-wins, sets updated_at + created_by),GetHandoff(key),ListHandoffs(project, repo)(keyset/ordered by updated_at DESC, matching the existing pagination idiom),DeleteHandoff(key). - Handler + routes extend the
chatStoreinterface (handler.go:24-34) +Routes()(handler.go:141-151):POST /handoffs(upsert),GET /handoffs?project=&repo=(list),GET /handoffs/{key},DELETE /handoffs/{key}. Mounted in the existing auth+metrics group - authz inherited (per-token OIDC,created_byfrom claims). - Metrics following
internal/chat/metrics.go.
Tests: migration-count guard stays green; store CRUD + upsert-latest-wins; handler routes; the auth group covers the new routes.
Component 2: tatara-cli handoff MCP tools¶
Chat tools are data-driven REST defs (Target: TargetChat, a Build closure returning method/path/body) dispatched via the shared generic client - no new client needed.
- Add
HandoffTools()aftertools.go:919(allTarget: TargetChat): write_handoff{handoff_key, project, repo, kind, body}->POST /handoffslist_handoffs{project, repo?}->GET /handoffs?project=&repo=get_handoff{handoff_key}->GET /handoffs/{handoff_key}delete_handoff{handoff_key}->DELETE /handoffs/{handoff_key}- Register in the concat at
server.go:57(all tools always registered for cache stability; call-time authz gates them - the Component-4a change). - Gating: add
groupHandoffnearprofiles.go:71-83+ ahandoff boolonprofileSpecunioned inresolveProfile(profiles.go:196-200). Allocation: write_handoff,get_handoff,list_handoffs: the work kinds that carry continuity - implement, issueLifecycle, incident, brainstorm, refine.delete_handoff: refine only (the groomer).- Update the ~7 hardcoded tool-count assertions (
tools_test.go:270-281,server_test.go:56,65,98,126,427,e2e_test.go:76) for the +4 tools.
Tests: the 4 tools build the right method/path/body; groupHandoff gating (refine can delete, others cannot; non-handoff profiles can't call any); tool-count assertions updated.
Component 3: tatara-claude-code-wrapper - drop S3, handoff preamble¶
- Remove the S3 path: the
convStorerestore/fork block (cmd/wrapper/app.go:89-140), theOnTurnDoneupload (app.go:220-245),resumeSID/ResumeSessionID, the 3Conversation*config fields (config.go:91-100,188-190),S3Config()/S3 fields, theinternal/convstore internal/storagepackages,ConversationOpsTotal, and their tests. KEEPCONVERSATION_OBJECT_KEYas the handoff key (rename its doc, not the env, to avoid an operator-side change).- Continuation preamble: in
postMessage(internal/httpapi/messages.go:69), when this is the pod's FIRST goal submission andCONVERSATION_OBJECT_KEYis set, prepend a short preamble to the goal text beforeSubmit:"Continuation key:
<key>. If you have prior context, callget_handoffwith this key before starting, andwrite_handoffan updated summary before you finish." Track "first submission" with a simple bool on the session/app. The wrapper itself does NOT call chat - the agent does, via the MCP tool. - The boot-crash fix already means fresh pods boot cleanly with no
--continue; removing S3 restore means pods are always fresh + carry only the handoff.
Tests: the preamble is prepended on the first submission only, with the key; absent when CONVERSATION_OBJECT_KEY is empty; no S3/convstore references remain (build + a grep-guard test).
Component 4: tatara-agent-skills - /handoff skill¶
Rework the /handoff skill to the chat-backed flow: - At start (resuming): call get_handoff{handoff_key} (key from the wrapper preamble); if present, load it as the working context; if absent, start fresh. - At end (checkpoint): call write_handoff{handoff_key, project, repo, kind, body} with a COMPACT markdown summary: current state, what's done, what's next, open questions, key file/PR refs. Keep it short (a summary, not a log). - Tag with the profiles: that carry continuity (implement, issueLifecycle, incident, brainstorm, refine) so TATARA_SKILL_PROFILE installs it there.
Component 5: tatara-operator - groom, prioritize, merge¶
- refine grooms handoffs: extend
refine.GoalProject(internal/refine/goal.go:34) to instruct: list_handoffs for the project; delete handoffs that are stale/done (issue closed/resolved, clearly superseded, or aged with no matching open work), keep the rest. refine's profile already gets list+delete (Component 2). - brainstorm prioritizes handoffs: extend
brainstormGoalProject(projectscan.go:1716) to instruct: first list_handoffs + propose continuing the open ones that still matter, THEN generate fresh ideas (subject to the existing maxOpenProposals cap). - cron-merge: re-scope the existing refine pre-scan barrier (
projectscan.go:2745-2799) so it fires on the brainstorm cron tick rather than theClosedLookbackDays>0opt-in. On a due brainstorm tick: create the refine Task,requeueRefineBarrier(30s poll); when refine reachesTaskTerminal, release intor.brainstorm(...). ReuseslatestTerminalRefineTask/stampRefine/refineNeededThisCycle. A Failed refine still releases the gate. Drop refine from gating mrScan/issueScan/ healthCheck (those no longer wait on it).ClosedLookbackDaysstays as the refine lookback input; its role as the enable-gate moves to "brainstorm enabled".
Tests (envtest): the barrier fires refine on a brainstorm tick and releases to brainstorm on refine-terminal; the refine goal contains the handoff-groom instruction; the brainstorm goal contains the handoff-prioritize instruction.
Cross-repo change list + build order¶
| Order | Repo | Change |
|---|---|---|
| 1 | tatara-chat | chat_handoffs slice (migration+model+store+handler+routes+metrics+tests) |
| 2 | tatara-cli | HandoffTools() + groupHandoff gating + tool-count assertions |
| 3a | tatara-claude-code-wrapper | remove S3 conv restore/upload; first-turn handoff preamble; bake the new cli |
| 3b | tatara-agent-skills | /handoff skill chat-backed rewrite |
| 3c | tatara-operator | refine-groom + brainstorm-prioritize goals; barrier re-scope to brainstorm tick |
1 and 2 are the foundation; 3a/3b/3c depend on the MCP tools existing but are independent of each other.
Deploy coordination¶
chat merges -> chat image (+ helmfile chat pin if chat is a helmfile release). cli merges -> cli image -> wrapper bakes TATARA_CLI_VERSION -> wrapper image -> helmfile agent.image. operator merges -> operator image + charts -> helmfile dual-pin. skills -> skillsRef pin bump. Sequence chat+cli first, then the three consumers, then a helmfile MR carrying all the pins. Gated on burn as usual; the fleet is currently running steady-state.
Acceptance criteria¶
- A fresh pod boots with NO S3 restore; the first goal carries the handoff preamble; an agent resumes via
get_handoffand checkpoints viawrite_handoff. - Handoffs are queryable in chat (
GET /handoffs?project=) and survive across pods. - refine deletes a stale handoff (e.g. a closed issue's) and keeps a live one.
- brainstorm proposes continuing an open handoff before fresh ideas.
- One brainstorm cron tick runs refine then brainstorm (barrier), no separate refine schedule.
- No S3 conversation code remains in the wrapper.
Related¶
docs/superpowers/specs/2026-07-04-token-conservation-design.md (component 3), docs/2026-06-28-refine-rework-design.md (the barrier this reuses), [[tatara-token-conservation-2026-07-04]], [[refine-agent-backlog-groomer-2026-06-28]], [[project-level-brainstorm]], [[envboolor-empty-bootcrash-2026-06-28]] (env-contract care when removing the S3/Conversation* vars).