Builds on 1. Chat app. Store the transcript in Postgres, restart the host, and recover the same thread. Completed replies survive unchanged. An interrupted echo restarts from its first word under the same message ID.
1. Postgres
- A database:
createdb chat, or without a local serverdocker run -d -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust -e POSTGRES_DB=chat postgres. uv add "pinned[postgres]"forPostgresStateStore.DATABASE_URL, for examplepostgresql://postgres@localhost/chat.
2. The host
Edit the chat tutorial's app.py. Replace lifespan, append_message, and runs with the versions below, add ChatLog, log, and persist, and replace the module-level store, worker, and app setup. Keep the five command handlers and the other helper methods, including transcript(). Drop the MemoryStateStore import:
import os
from contextlib import asynccontextmanager
from pinned import Tray, persistent, tray
from pinned.durability.postgres import PostgresStateStore
class ChatLog(Tray):
messages = persistent(None)
def items(self):
yield from ()
class ChatHost(Statewire):
@tray(ChatLog)
async def log(self, item, ctx):
raise RuntimeError("the chat log admits no work items")
async def lifespan(self):
self.chat_log = await self.log.get()
self.state = {
"messages": plain(self.chat_log.messages)
or {"mainBranchId": MAIN, "branches": {MAIN: {"headId": MAIN}}, "messages": {}},
"threads": {MAIN: {"status": "idle"}},
}
self.thread = linear_thread(
messages=self.transcript,
role=lambda m: m["role"],
)
yield
def persist(self) -> None:
self.chat_log.messages.value = plain(self.state["messages"])
def append_message(self, message: dict) -> str:
branch = self.state["messages"]["branches"][MAIN]
entry = {**message, "parentId": branch["headId"]}
self.state["messages"]["messages"][entry["id"]] = entry
branch["headId"] = entry["id"]
self.persist()
return entry["id"]
@run_manager(capabilities=("files",))
async def runs(self, ctx: RunManager.RunContext):
if not ctx.resume:
for message in ctx.messages:
self.append_message(message)
ctx.applied()
messages = self.transcript()
user = next(m for m in reversed(messages) if m["role"] == "user")
self.state["threads"][MAIN]["status"] = "streaming"
if messages[-1]["role"] == "assistant":
reply_id = messages[-1]["id"]
else:
reply_id = self.append_message(
{"id": f"a-{uuid.uuid4()}", "role": "assistant", "parts": [{"type": "text", "text": "", "state": "streaming"}]}
)
part = self.state["messages"]["messages"][reply_id]["parts"][0]
part["text"] = ""
part["state"] = "streaming"
words = ["echo:", *(w for p in user["parts"] if p["type"] == "text" for w in p["text"].split())]
for word in words:
if ctx.stop_requested.is_set():
break
await asyncio.sleep(0.05)
append(part, "text", word + " ")
part["state"] = "done"
self.state["threads"][MAIN]["status"] = "idle"
self.persist()
return RunManager.Stop(dispatch_queue=True) if ctx.stop_requested.is_set() else RunManager.Complete()
store = PostgresStateStore(dsn=os.environ["DATABASE_URL"])
worker = PinnedWorker(hosts={"/threads": ChatHost}, state_store=store)
@asynccontextmanager
async def lifespan(app):
await store.setup()
yield
app = FastAPI(lifespan=lifespan)
app.include_router(worker)How it works:
- A
Trayis a durable slot on the instance.ChatLoghas onepersistentfield; itsitemsyields nothing and its handler never runs, since the log admits no work items (trays,persistent). lifespanrestores the transcript.transcript()follows the stored parent links to recover conversation order.persist()writes through.valueafter each message is added and when a run ends. The assistant message ID is saved before streaming starts; text streamed during a run is saved at its end.runsnow handles a redelivery. After a crash followingctx.applied(), the machine calls it again withctx.resumeset. The run reads the last user message from the saved transcript, reuses its assistant message ID, and streams the echo again (resumes and failures).await store.setup()at boot creates the tables (state stores).
3. Start it
DATABASE_URL=postgresql://postgres@localhost/chat uv run uvicorn app:app --port 80004. Restart and resume
Create attach.mjs: tutorial 1's smoke.mjs with the await h.sendMessage(...) line removed. Send a message, stop the server with Ctrl+C, start it again, and attach. The last transcript line from each command is shown:
$ MESSAGE="remember me" node smoke.mjs # before the restart
user: remember me | assistant: echo: remember me
$ node attach.mjs # after the restart
user: remember me | assistant: echo: remember meattach.mjs reads the saved conversation. Check that the completed reply is unchanged.
Crash during a reply
Keep the completed conversation above, then test another turn:
-
Send a message long enough to interrupt its echo:
MESSAGE="$(node -e 'console.log("recover ".repeat(200))')" node smoke.mjs -
Once several words have streamed, run
kill -9 <uvicorn-pid>in another terminal. Use the PID in Uvicorn'sStarted server process [...]log line. Stopsmoke.mjswith Ctrl+C. -
Start the host again with the same
DATABASE_URL, then runnode attach.mjs. -
Check that the earlier reply stays unchanged and the interrupted reply streams from its first word to completion. The thread still has two user messages and two assistant messages.
-
Run
MESSAGE="after recovery" node smoke.mjs. A new turn extends the recovered conversation.
What survives
- The transcript:
pinned_tray_indexholds one row per tray:ns='/threads', id='t1', tray_ns='log', tray_id='~', marked=falsefor the chat log. - Each client's command pool, row
('/threads', 't1', 'client_commands', '<client id>', ...): admitted commands awaiting execution remain queued across a crash. - A run in flight: its tray redelivers the work on the next activation (requirements).
ctx.resumeis true when the batch was durably applied before the crash (Runs). A settled run's tray deletes itself.
The durability model is on durable objects. The hosting knobs are on fleet.
What you built
A thread that outlives its process: completed turns are restored, and an interrupted echo restarts under its saved message ID.
- Next: 3. Fleet puts pinboard in front, so threads spread across many machines.