Skip to content

Latest commit

 

History

History
333 lines (237 loc) · 16.5 KB

File metadata and controls

333 lines (237 loc) · 16.5 KB

Session Operations

Each operation is written out end to end, from trigger to disk to frontend.


Startup

When the process starts, SessionStore runs a one-time initialization:

  1. Read index.json and load it into the in-memory _index dict
  2. If the file does not exist or JSON parsing fails → scan the meta.json of every session directory to rebuild _index, then write index.json
  3. Iterate over _index and reset every status=running to idle (crash recovery)
  4. Clean up empty shells: a session with 0 messages and created more than 1 hour ago → delete the directory + delete the registry entry
  5. Clean up expired archives: archived=True and updated_at older than 90 days → delete
  6. Capacity check: if the registry exceeds 1000 entries → delete the oldest archived sessions in ascending updated_at order

Handling of half-broken sessions:

  • Has meta.json but no history/ → treated as an empty shell, deleted in step 4
  • Has history/ but no meta.json → the scan in step 2 cannot read meta.json, so it is not registered and is treated as nonexistent

Creating a session

Three entry points can trigger creation:

Entry point Scenario
dispatcher.process_user_turn When a user sends a message and the session does not exist, create it
channel handler Create it when a channel message arrives
session_context When the CLI / research harness enters the context and the session does not exist, create it

No other place creates a session.

Full flow

Caller calls create_session(session_id, agent_id, source=..., ...)
  → Create the <state>/sessions/<session_id>/ directory
  → Write meta.json (id, agent_id, title, created_at, updated_at, source, status="idle", ...)
  → Write the registry: _index[session_id] = summary entry
  → Atomically write the registry to disk (temp file → os.rename)
  → No broadcast (the frontend discovers the new session via list_sessions)

Atomicity

For the dispatcher and the channel handler, creation and writing the first message are atomic — append_message is called immediately after creation, so no empty shell is produced.

session_context creates the session in __enter__ (because the subsequent ContextVar loading needs a valid session). If it exits abnormally before writing any message, an empty shell is produced, which is handled by the startup cleanup.


Main project binding

A session's main working directory is the path of the project it is bound to. The binding is chosen freely while the chat is still a draft, and freezes when the first real message commits — from that turn onwards the conversation's cwd, its permission fence baseline, and the location of its session repo (<project>/.openprogram/sessions/<id>/) all stay put. That is the whole point of freezing: those three follow the main directory, and only create_session can place the repo, so a later switch would leave the repo stranded in the old project while the model worked in the new one.

Additional working directories are the opposite and are meant to be: they are added and removed at any point in the session's life. See additional-working-directories.md.

Where the choice is made

draft chat, project picked in the composer chip
  → pendingProjectsByChat[chatKey]  (frontend store, nothing sent yet)
  → first chat frame carries project_id
  → handle_chat creates the session WITH that project → repo lands inside it
  → chat_ack sends the idempotent set_session_project (label + reverse index)

set_session_project accepts a bind naming the project the session already has, at any age — that is what the post-ack idempotent bind above sends, and it arrives after the first turn is committed. A bind naming a different project on a session that already has turns is rejected with project is frozen after the first turn (ws_actions/project.py:FROZEN_ERROR). The composer chip matches: for a session with an id it renders the bound project read-only instead of the picker.

Repairing a missing directory

A frozen main directory can still be relocated, and only for repair: the folder was moved or renamed on disk and the project now points at nothing. project_workdir_for returns None in that state — it never silently substitutes the default project's home directory — so the turn's tool cwd falls back to the session's own workdir/, and project_path_missing names the vanished path. list_projects ships that as path_missing per project, which is what turns the composer chip orange and puts "Locate folder…" in its menu.

The repair is the relocate_project ws action:

relocate_project {session_id, project_id, path}
  → project_store.relocate_project: validate the new directory, rewrite the
    project's path, KEEP its id (sessions, settings and the frozen binding all
    hang off the id)
  → record_relocate: append the graph node below
  → project_relocated {ok, old_path, path, node_id} + a fresh projects_list

It changes the project's path, never the session→project binding, which is why it stays legal after the freeze. Every session bound to that project follows the move. The default project refuses to relocate: its path is the home directory, restored on every get_default_project read.

The relocate record node

The move changes where every later turn runs, so it is recorded in the session graph rather than mutating the registry silently (openprogram/store/project/relocate_node.py):

Field Value
role code
name project/relocate
caller ROOT
predecessor None
output <old path> → <new path>
metadata {display: "runtime", project_id, old_path, new_path}

The shape follows context/system_prompt (dag/overview.md §3, §7): the write invariant constrains only conversational nodes, and because caller is set the store leaves head where it is — recording a relocation never moves the branch tip. The name is deliberately not under context/: those are pipeline machinery hidden from the transcript, while a relocation is a user action worth seeing.


Writing a message

Caller calls append_message(session_id, msg)
  → Write the message to the DAG (Git history/)
  → If msg.role == "user":
      → preview = take the first 80 characters of msg.content
      → _index[session_id]["preview"] = preview
      → _index[session_id]["updated_at"] = time.time()
      → Mark the registry dirty (write to disk with a 5-second debounce)
  → No broadcast (message content is pushed through a separate streaming channel)

preview truncation

def _truncate(text: str | None, max_len: int = 80) -> str | None:
    if not text:
        return None
    t = text.strip().replace("\n", " ")
    return t[:77] + "…" if len(t) > max_len else t

Throttling registry writes to disk

When append_message updates the registry, memory is updated immediately, but the disk write is debounced (at most one write per 5 seconds). The registry is flushed on process exit. If the process is SIGKILLed and the flush fails, recovery is simply a matter of rebuilding from meta.json at startup, losing at most 5 seconds of preview updates.

Other operations (create, update, delete) write to disk atomically and immediately.


Updating fields

Updates to the title, status, pinned, archived, unread, and other fields all go through the same path:

Caller calls update_session(session_id, title="New title", pinned=True, ...)
  → Write meta.json (only the fields that were passed in are updated)
  → Update the corresponding fields in _index[session_id] + updated_at
  → Atomically write the registry to disk

The broadcast is initiated by the WebSocket handler layer via _broadcast after it calls update_session; both rename and flags go through the broadcast:

→ Broadcast session_updated:
  {"type": "session_updated", "data": {"id": "<session_id>", "title": "New title", "pinned": true}}
→ After the frontend's handleSessionUpdated receives it, it patches the corresponding session and re-renders

data contains only the changed fields, and the frontend does an incremental patch.

When status is written

The dispatcher writes status during the turn lifecycle:

Timing Value written
Turn start update_session(session_id, status="running")
Turn ends normally (foreground) update_session(session_id, status="idle")
Turn ends normally (background) update_session(session_id, status="done", unread=True)
Turn fails update_session(session_id, status="failed")
Waiting for user input update_session(session_id, status="needs_input")

Naming

Naming has only one authoritative implementation: openprogram/agent/dispatcher/titles.py. Naming from all entry points (WS / fn-form / channel / CLI / spawn) converges on finalize_turn end → _maybe_auto_title; there is no second truncation/lock logic. Title writes all go through update_session(session_id, title=...), following the full "Updating fields" flow above.

Lock markers (authoritative, only two + one internal counter)

Marker Type Meaning Who sets it
_user_titled bool User manually renamed → permanent lock, auto-naming never runs again Only the rename operation sets it, when the user has entered a name
_auto_titled bool Auto-naming has produced at least one title (first-round truncation or any LLM write) → a "don't re-truncate" dedup bit Only _maybe_auto_title sets it
_title_gen_count int Internal counter for progressive renaming (which entry of _RETITLE_AT_TURNS was hit) Internal to _maybe_auto_title, not an entry-point lock

There is no single marker that means both "truncated" and "permanently locked": one marker doing both would conflict with the two-phase flow, where truncation is expected to be superseded by the LLM title. Entry points therefore do not truncate or set a lock of their own; _maybe_auto_title handles phase 1 (truncation) and phase 2 (LLM) uniformly. The only lock an entry point may set is _user_titled, set by the rename operation.

Auto-naming (progressive, two phases)

Auto-naming triggers multiple times as the conversation evolves, producing more precise titles as context accumulates. Trigger thresholds: at the 1st, 6th, 16th, and 40th assistant reply (_RETITLE_AT_TURNS).

finalize_turn end → _maybe_auto_title:
  1. Check _user_titled → if the user manually renamed, never auto-rename
  2. Count the current number of assistant messages → skip if no threshold is hit
  3. First time (turn 1, phase 1 immediate truncation):
     a. title = _title_from_text(user's first message)
        (strip [attachment:]/<attachment-preview>/<file> markers → take the first line → truncate to 50 chars, appending … if it overflows)
        → update_session(session_id, title=truncated value, _auto_titled=True, _title_gen_count=1)
     b. Start a background daemon thread to call the LLM (phase 2)
  4. Subsequent thresholds (turn 6/16/40):
     a. Directly start a background daemon thread
     b. The LLM input takes the most recent 20 messages (not just the first round)
  5. Background thread (phase 2):
     → Race check: give up if _user_titled
     → On the first time, also check whether title is still the phase 1 truncated value (give up writing if it has been changed)
     → Write update_session(session_id, title=LLM result, _auto_titled=True, _title_gen_count=N+1)
     → Broadcast session_updated

channel (WeChat / Discord, etc.)

Channel conversation naming is exactly the same as for ordinary conversations, going through the same two-phase LLM naming. The channel side does not perform any additional operation / lock / intervention on the title content (it does not set _user_titled, does not set _auto_titled, and does not pre-truncate). The source identifier is only added as a bracketed brand prefix in the frontend display layer (e.g. [WeChat] Weekend plan discussion); it does not go into the title itself.

Empty conversations

Creation and writing the first message are atomic, so empty conversations are not normally produced; should one occur, it is handled by startup cleanup or manual deletion. The naming layer does no special filtering for empty conversations.

User-initiated rename

  • Manually enter a new name → update_session(session_id, title=new name, _user_titled=True) After _user_titled is set, auto-naming stops permanently.
  • Have the LLM regenerate (click the button, title is empty) → _llm_rename()update_session(session_id, title=LLM result) _user_titled is not set, and auto-naming continues.

For the details of LLM title generation (prompt, parameters, post-processing), see name.md.


Listing

The frontend sends the WebSocket message {"action": "list_sessions"}
  → handle_list_sessions:
      → session_store.list_sessions():
          → Iterate over the in-memory _index.values()
          → Drop archived rows unless include_archived=True
          → Filter by filters
          → Sort by updated_at in descending order
          → Return rows[offset:offset+limit]
      → Fill in the project field (mapped from the project directory)
      → Send {"type": "sessions_list", "data": rows}
  → The frontend renders the sidebar and the Chats page

Purely an in-memory operation; it does not touch disk.

Archived rows

list_sessions hides archived sessions by default — archiving only bounds the list if the default listing honours it. Two ways to see them:

  • include_archived=True returns archived and active rows together
  • archived=True returns only the archived ones

Maintenance passes that must visit every session regardless of the flag — the memory scan, run-state repair, "clear all", channel-binding lookup, agent addressing — pass include_archived=True explicitly. The sidebar payload also sends every row and lets the client switch between the active, archived, and all views without a round trip.

Fields returned per session

The 15 fields in the registry + preview + project (filled in during listing), 17 in total. See storage.md for the complete list.


Deleting

Caller calls delete_session(session_id)
  → Delete the entire <state>/sessions/<session_id>/ directory
  → Delete _index[session_id]
  → Atomically write the registry to disk
  → Broadcast session_deleted:
    {"type": "session_deleted", "session_id": "<session_id>"}
  → After the frontend receives it, remove it from the list

The registry operation is internal to delete_session. The broadcast is initiated by the WebSocket handler layer via _broadcast.


Archiving

Archiving keeps the session list from growing without end. It is a single boolean on the session meta — nothing is deleted, nothing is moved, and the operation is reversible at any time.

Caller calls set_archived(session_id, True)
  → update_session(session_id, archived=True)
  → Follows the full "Updating fields" flow
  → After the frontend receives the broadcast, it filters the display

set_archived returns False for an unknown session id, so the CLI and the REST endpoints can report a missing session instead of silently succeeding.

What archiving does not touch

updated_at records the last time a message was appended, and archiving appends nothing — so it leaves the timestamp alone. This is the index consistency contract: an archived session returns to exactly its old place in the list when unarchived, rather than jumping to the top. Messages, branches, and history files are untouched; get_messages keeps working while a session is archived.

Entry points

Surface Operation
WebSocket {"action": "update_session_flags", "session_id": ..., "archived": true}
REST POST /api/sessions/archive / POST /api/sessions/unarchive, body {"session_id": ...}
CLI openprogram sessions archive <id> / openprogram sessions unarchive <id>

All three write the same flag through set_archived and broadcast session_updated, so every open tab agrees.

Archived sessions are subject to the startup-time data maintenance constraints: 90-day expiration + the 1000 capacity cap. Active sessions are not affected.


Writing the registry to disk (general)

All registry-to-disk writes use atomic writes:

Write to the temp file index.json.tmp
  → os.rename(index.json.tmp, index.json)

This prevents file corruption caused by crashes.