@turmind/halo 1.6.7 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/app/page-c9fb252d0cf34c20.js +1 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +2 -2
- package/bundled-docs/design/agentcore.md +27 -0
- package/bundled-docs/design/architecture.md +4 -4
- package/bundled-docs/design/background-dispatch.md +3 -3
- package/bundled-docs/design/cron.md +12 -10
- package/bundled-docs/design/halo-city.md +7 -7
- package/bundled-docs/design/session.md +99 -80
- package/bundled-docs/design/storage.md +66 -49
- package/bundled-docs/design/web.md +4 -0
- package/bundled-docs/design/ws.md +4 -3
- package/bundled-docs/dev/add-model-provider.md +1 -0
- package/bundled-docs/dev/api.md +15 -12
- package/bundled-docs/dev/deploy.md +2 -0
- package/bundled-docs/dev/env.md +1 -1
- package/bundled-docs/dev/tools.md +3 -1
- package/bundled-docs/guide/cli.md +16 -0
- package/bundled-docs/guide/sessions.md +1 -1
- package/bundled-docs/guide/testing-agents-and-skills.md +2 -2
- package/bundled-docs/requirements/chat.md +1 -1
- package/dist/index.js +3435 -1789
- package/package.json +1 -1
- package/templates/agents/__score__/AGENT.md +1 -1
- package/templates/schema.sql +5 -1
- package/admin-out/_next/static/chunks/app/page-3584adfe2f9a0913.js +0 -1
- /package/admin-out/_next/static/{x5jl6YGvzL0Ys4lxsHVg1 → 52ayDC9phpvb-95S7qHHq}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{x5jl6YGvzL0Ys4lxsHVg1 → 52ayDC9phpvb-95S7qHHq}/_ssgManifest.js +0 -0
|
@@ -4,73 +4,74 @@ Session lifecycle management, centred on SessionManager.
|
|
|
4
4
|
|
|
5
5
|
## Unified storage
|
|
6
6
|
|
|
7
|
-
Every session lives
|
|
7
|
+
Every session lives in `.halo/sessions/{agentId}/` as two append-only JSONL logs — `{seg}.ui.jsonl` (the UI view) and `{seg}.raw.jsonl` (model history) — plus gzipped UI archive segments; `{seg}` is the id's leaf segment. No parent/child split — `agentId` determines the directory. Metadata (title, counts, tokens, output) lives only in the `agent_sessions` row. Format in [storage.md](storage.md#session-files-20).
|
|
8
8
|
|
|
9
9
|
> **History**: earlier builds split files by origin (`sessions/explorer/main/` for the main chat, `sessions/delegated/{agentId}/` for sub-agents, `sessions/test-chat/{agentId}/` for test chat). Everything is now unified under `sessions/{agentId}/`.
|
|
10
10
|
|
|
11
11
|
### Internal-agent sessions live globally
|
|
12
12
|
|
|
13
|
-
Sessions for "internal" agents (`__evo_agent__`, `__score__`, `__apply_agent__`, future platform tooling — anything whose id matches `__*__`) are special-cased. They don't belong to any user workspace, so
|
|
14
|
-
|
|
15
|
-
These sessions also do **not** get an `agent_sessions` row in the workspace's `halo.db`. To make `cli -s <id>` resume them, `SessionManager.ensureSession` and `getSessionById` fall back to a directory scan over `internal-sessions/` (`findInternalSession` in session-store.ts) when no db row exists. This keeps the user's workspace db clean of platform-tooling rows; channel `/session list` and admin session listings naturally don't see them.
|
|
16
|
-
|
|
17
|
-
## Session file format
|
|
18
|
-
|
|
19
|
-
```json
|
|
20
|
-
{
|
|
21
|
-
"id": "bkacd7fnmoaxrwbv",
|
|
22
|
-
"agentId": "default",
|
|
23
|
-
"agentName": "Default",
|
|
24
|
-
"title": "First 60 chars of first user message",
|
|
25
|
-
"source": "explorer",
|
|
26
|
-
"createdAt": "2026-04-19T...",
|
|
27
|
-
"updatedAt": "2026-04-19T...",
|
|
28
|
-
"messageCount": 42,
|
|
29
|
-
"contextTokens": 85000,
|
|
30
|
-
"totalOutputTokens": 12000,
|
|
31
|
-
"parentSessionId": null,
|
|
32
|
-
"archiveCount": 2,
|
|
33
|
-
"archivedUserCount": 30,
|
|
34
|
-
"messages": [...],
|
|
35
|
-
"rawMessages": [...],
|
|
36
|
-
"output": "...",
|
|
37
|
-
"lastActivityAt": "2026-09-18T..."
|
|
38
|
-
}
|
|
39
|
-
```
|
|
13
|
+
Sessions for "internal" agents (`__evo_agent__`, `__score__`, `__apply_agent__`, future platform tooling — anything whose id matches `__*__`) are special-cased. They don't belong to any user workspace, so their files live under `~/.halo/global/internal-sessions/<agentId>/` regardless of which workspace the cli was launched against. `getSessionDir()` in `sessions/session-store.ts` does this routing.
|
|
40
14
|
|
|
41
|
-
|
|
42
|
-
- **rawMessages**: Bedrock API shape (written by SessionManager `saveAgentState`) — raw user/assistant turns with toolUse/toolResult blocks
|
|
43
|
-
- **output**: accumulated assistant text of the latest turn
|
|
44
|
-
- **lastActivityAt**: ISO time of the latest turn's last text / tool event (null if none) — written alongside `output` by `saveAgentState` so a released session's `get_session_output` can still report liveness
|
|
45
|
-
- **archiveCount / archivedUserCount**: UI-log archiving bookkeeping — absent until the first archive. See [UI-log archiving](#ui-log-archiving)
|
|
46
|
-
- **title**: set once, then sticky (a later write keeps an existing title other than `New session`; a rename via `PATCH /sessions/logs/:id` overwrites it). Where it comes from depends on the session kind:
|
|
47
|
-
- **Root**: the first user message (editor-context prefixes stripped, 60 chars) via `saveSessionToFile`, or the explicit `title` passed to `createSession`, which pre-seeds the file before the first run.
|
|
48
|
-
- **Sub-session**: its `description` — the brief — truncated to 60 chars (`saveSessionToFile` for `delegated` logs, and `saveAgentState`).
|
|
15
|
+
Their metadata row lives in a global db next to the files, `~/.halo/global/internal-sessions/halo.db` (`internalSessionsDb()`, same `agent_sessions` schema, same code path). `createSession` inserts it there as well as in the workspace db the run started in (the workspace row carries this process's lifecycle updates — stopped / archived / updated — as in 1.x, and its metadata columns stay as created); `sessionMetaDb()` / `SessionManager.metaDb(agentId)` picks the global one for every metadata read / write, including the list, the session GET, `getSessionTitle` and the rename, and `ensureSession` falls back to it when the workspace db has no row — the workspace it ran in (an evo sandbox) may be gone. Deleting an internal session removes its subtree's rows from the global db with the files. A 1.x internal session with no row gets one from its `.json` header when it is migrated.
|
|
49
16
|
|
|
50
|
-
|
|
17
|
+
## Session files
|
|
18
|
+
|
|
19
|
+
- **`{seg}.ui.jsonl`** — the event log (written by the UIState reducer) as `add` / `patch` / `inflight` records: context / usage / tool_call / tool_result / agent_start/done, with full debug info. Assistant rows persist their tool calls **only** in `contentBlocks` (interleaved with text / thinking); the flat `toolCalls` array is not written — it duplicated every tool's input *and* output byte-for-byte (measured: 66 MiB of 340 MiB across 195 sessions). Readers are blocks-first with `toolCalls` as a pure legacy fallback (`messageToolCalls()` in `sessions/session-types.ts`; see [storage.md](storage.md#sessionmessage))
|
|
20
|
+
- **`{seg}.raw.jsonl`** — model history, one Bedrock-shape message per line (written by SessionManager `saveAgentState`); never archived — compaction rewrites it
|
|
21
|
+
- **`{seg}.ui.arch.{N}.{h}.jsonl.gz`** — archived UI history, see [UI-log archiving](#ui-log-archiving)
|
|
22
|
+
|
|
23
|
+
No file holds metadata. Everything the 1.x header carried is derived or in the session's db row: agentId = directory, leaf id = file name, archive count = segment files on disk; `title`, `exchange_count`, `context_tokens`, `total_output_tokens`, `output`, `last_activity_at` = row columns (see [SQLite metadata](#sqlite-metadata)).
|
|
24
|
+
|
|
25
|
+
**Writes append.** `sessions/session-log.ts` keeps a process-local writer cache per file (what this process last wrote or read, stamped with the file's ino / size / mtime). A save diffs memory against it **by object identity** — a persisted row or raw message is never edited in place; every mutator replaces the object (raw: or its `content` array, or changes its length) — and appends only the new records with one `write` on an `O_APPEND` fd. A full atomic rewrite (tmp + rename) happens only for real non-append changes: compaction (raw), archive / `replaceMessageLog` / `deleteExchange`, a stamp that no longer matches (another process — a cron `halo cli` child, CLI/TUI — wrote the file: rewrite from this process's memory, the same last-writer-wins as 1.x), or the first write of a file in this process. The stamp is checked at the write itself, not by a stat before every save: an append or tail write compares it with the `fstat` of the fd it writes through and turns into a full rewrite on a mismatch; only a save that is a full rewrite anyway stats first (its exchange-count delta is relative to what the file holds); a save with nothing new touches nothing. **Another process's write** (a cron `halo cli` child, the TUI, a manual `halo cli -s`) is read, not overwritten, before this process uses its cached copies again. The root's two copies — the UIState (kept past release) and an idle AgentSession a view loaded — can part wherever one of them is read from disk while the other is still cached, so the check sits at those points, one stat per cached copy against its writer baseline: `appendUserMessage` (a turn's user row, checked before it lands — dropping the state after would discard it; this precedes the `ensureSession` check on the WS path), `ensureSession`'s restore (raw about to be read; the cached UIState is checked), `ensureUIState`'s disk seed (the UI about to be read; a cached idle root AgentSession whose raw changed reloads its history in place — `refreshStaleHistory`, in place because callers such as `querySession` hold the object and its queue), plus `deleteExchange` (reads UI before raw), manual `compactSession` (its UI seed runs after `beginCompact`, past the seed's guard) and `querySession` waking an idle root with both copies cached (no point above runs: `ensureSession` returns the cached session, the emit doesn't seed) — only when the session was already in memory before the await (a cold wake's restore has just checked), and there only the UIState is dropped, after `ensureSession`'s await; the emit re-seeds it and `refreshStaleHistory` reloads the history in place. Never the AgentSession: a sub's auto-report calls `querySession` from its `finally`, before its release, and dropping the tree there would skip that release's flush and save. A file with no baseline — the load found a torn line or another process's open in-flight row — counts as written elsewhere unless the UIState holds unsaved rows. On a hit the tree's idle copies are dropped (`forgetExternalWrite`, which cron also calls when its child exits) and both files reload from disk; the dropped UIState's pending debounced writes are cancelled, not flushed, when the UI log itself was written elsewhere (a flush would rewrite the other writer's file from the stale state) — a `system` / `user` row from the last ≤500ms before the other writer wrote is the cost; after a raw-only write elsewhere the UI file is still this process's, and they flush as usual. The view path (`prepareForView` re-seeding a root this process doesn't drive) cancels the same way when the file was written elsewhere, and flushes otherwise. Skipped while the tree has a turn or compact in flight or a queued message — live state isn't dropped, and the two writers race (the last full rewrite wins). So a normal turn after a release stats the UI log twice (`appendUserMessage`, then the restore). Known gap: another process starting a turn in the few ms between `appendUserMessage` and `ensureSession`'s restore (the `buildAgentInstance` await) makes the restore's check drop the UIState — this process's user row, already in it, is then missing from the UI log, though the turn and its raw copy run. Not when the other process is mid-turn: the seed taken over its open in-flight row has no baseline, and a state with unsaved rows and no baseline isn't dropped. Whenever a save does replace another process's write (a copy still cached with no check in between: a notification, a Stop fold), the session's other log is rewritten whole from memory in the same save (`writeUILog` → `foreign`, `writeRawLog` → true; `persistUILog` / `saveAgentState` pair them), so the two files stay a pair, never one with the other process's turns beside this process's. One exception: a sub-session's raw write after its `agent_done` rewrites raw only — its UI log left the root's UIState at `agent_done`, so there is no copy to pair it with. Never spliced into the other's file either way. Loaders that hand their result to the live session (`prime`) seed the cache, so the first save after a restore appends. Writes stay synchronous on the same call sites as 1.x, so UI and raw read→diff→write never interleave, the tombstone check and the write share one synchronous slice, and `flushAll` lands before exit. A torn last line (crash mid-write, or an EFS unmount) fails to parse: the reader drops it and the next write is a full rewrite.
|
|
51
26
|
|
|
52
|
-
|
|
27
|
+
**The in-flight assistant has a stable id.** `TurnState.inflight = { id, at }` is assigned by the first snapshot of a streaming reply and reused by `flushAssistantMessage` / `flushCompletedAssistantMessage` for the finalized row. Every persist mid-stream writes an `inflight` record carrying only the changed blocks; the final `add` with the same id retires it — exactly one row, and live views (WS / web snapshots) show the same id. An interrupt flush writes the partial reply under that id, raw gets the streamed text + `INTERRUPTED_REPLY_MARKER`, so after a reload both halves hold the partial reply once. SIGKILL loses what streamed after the last write.
|
|
53
28
|
|
|
54
|
-
|
|
29
|
+
**UI and raw stay in step.** The two logs are written at the same points, in the same synchronous slice, so a retry, an abandoned call, a restart or a kill leaves both telling the same story:
|
|
30
|
+
|
|
31
|
+
- **Save points.** `tool_call` (the tool_use turn), each `tool_result` (it lands in the history as it finishes — `agent-loop` no longer holds a batch's results until the batch ends), `usage` (the finished call, including the final reply without tool calls), and a successful compaction (LLM summary, its local fallback, the context-overflow local compact — saved at once, so a hard kill can't restore the pre-compact raw under a UI log already showing the summary). The UI flushes on the same events instead of debouncing. A change confined to the raw log's last message (a result landing in the batch's results message) rewrites that one line in place — see [storage.md](storage.md#session-files-20).
|
|
32
|
+
- **Retry.** A failed call that streamed text / thinking and is about to be retried emits `call_discarded` (before the retry notice): the UI drops that call's blocks from the turn by `turnId`, the admin gets `chat:discard` ([ws.md](ws.md)). The model never saw them either — the retry re-streams from scratch.
|
|
33
|
+
- **Abandoned call** (account error, unrecoverable error after the retries): the UI keeps the partial; raw gets it too, suffixed with `ERROR_REPLY_MARKER`, saved at once. Only the last attempt's partial — earlier attempts were discarded on retry.
|
|
34
|
+
- **Server shutdown** (`landForShutdown`, called at shutdown and once more right before exit): flushes the unpersisted UI logs, then writes the raw log of every session this process is **running** (`promise !== null`, subs too), a half-streamed reply as a **copy** + `SHUTDOWN_REPLY_MARKER` (the live history is left alone) and the row's `output` / `last_activity_at` (`saveRow` — a throttled trailing write would die with the process). An idle session in memory (loaded by `/context`, a manual `/compact`, a `query_session` target) is never written: every change to its history was saved on the spot, and its copy may be stale — a cron `halo cli` run or the TUI may have written the same session since, and a write from memory would replace those turns (stamp mismatch → full rewrite). The streamed-text buffer a copy is made from is cleared the moment the call's assistant turn lands in the history (`run()`'s `onAssistantLanded`), so neither the shutdown copy nor the error landing ever duplicates a reply already there. It ends nothing — no abort, no repair, no release, the run ledger untouched; the next boot nudges the runs and repairs their unanswered tool_use on load. What it lands is final, because nothing new starts once shutdown has begun (`beginShutdown`, see **Shutdown** under [Boot reconcile](#boot-reconcile-of-crash-orphans-reconcileorphansonboot)): no model call, no shell command. Idempotent. A session mid-compact keeps its raw log as is: its live history holds the summarize turn, and its raw on disk is the pre-compact state every result already reached. Its row is written all the same — row and raw don't depend on each other.
|
|
35
|
+
- **Hard kill.** What landed at the last save point stays; an unfinished tool call is repaired on the next load, a stream after the last write is lost.
|
|
36
|
+
|
|
37
|
+
**A failed 1.x conversion blocks the session.** Opening a session whose 1.x file can't be converted (bad file, read-back check failed, permission) throws `LegacyConversionError` (`<seg>: 1.x conversion failed: <reason> — run \`halo sessions migrate v1 v2\``) from every reader and writer of that session instead of opening it empty — an empty seed would show, and the next write persist, a truncated history. Every open retries (the full error is logged once per process). `ensureSession` refuses to restore it, so a send to it fails visibly on every entry point — REST / WS / CLI with an error, the web channel's SSE with an `error` frame after `session` ([web.md](web.md#a-failure-ends-the-stream-with-error)); per-row readers — the session list, `getSessionTitle`, a cold `get_session_output` — catch it for that row, so one broken session never breaks a list. The boot sweeps (run ledger, goal mode) skip it.
|
|
38
|
+
|
|
39
|
+
**Conversion is serialized across processes.** Every process that opens a session's files converts a 1.x session on first open (the server, a cron `halo cli` child, the TUI, the migrate command), so one session's conversion runs under `<seg>.migrating` (O_EXCL, holding the converter's pid). A lock held by a live pid is waited on (up to 10s, synchronously, in 50ms steps), and the waiter goes on as soon as the conversion has landed (`ui.jsonl` present **and** the 1.x file gone — `ui.jsonl` alone appears before the holder's read-back check, and appending then would make that check fail); past the wait the open throws like a failed conversion. A lock held by a dead pid, by this process's own pid (a dead predecessor's, reused), or empty and older than the wait is taken over. The rollback after a failed check runs only while still holding the lock. Single-host only (not NFS); a pid reused by a live process reads as alive — the open then waits the full 10s and throws, and the next open retries.
|
|
40
|
+
|
|
41
|
+
- **title**: set once, then sticky — a UI-log write sets the row's `title` only while it is empty (`updateSessionMeta`'s `titleIfUnset`); a rename via `PATCH /sessions/logs/:id` overwrites it. Where it comes from depends on the session kind:
|
|
42
|
+
- **Root**: the first user message (editor-context prefixes stripped, 60 chars), or the explicit `title` passed to `createSession`, which lands in the row on insert.
|
|
43
|
+
- **Sub-session**: its `description` — the brief — truncated to 60 chars (UI-log write for `delegated` logs, and `saveAgentState`).
|
|
44
|
+
|
|
45
|
+
Before 1.5.3-alpha `saveAgentState` stamped `description` on **any** untitled session. For a root that is a creation label (`Explorer chat`, `Telegram: …`), and that write usually beat the first debounced UI-log persist, so the label became every new root's permanent title.
|
|
55
46
|
|
|
56
|
-
|
|
47
|
+
When loading, the UI log takes priority; only when it is empty (e.g. a sub-session tracked only by SessionManager) does the raw log get converted to display format.
|
|
57
48
|
|
|
58
|
-
|
|
49
|
+
**1.x files** (`{seg}.json` holding both halves + a header, segments `{seg}.arch.{N}.json.gz`) are read only by `sessions/legacy-migrate.ts`: the first open of a 1.x session converts it, checks the 2.0 files read back equal, then deletes the 1.x files; `halo sessions migrate v1 v2` does a whole workspace at once. See [storage.md](storage.md#migration-from-1x) A conversion that fails blocks the session, and conversions are serialized across processes — see above.
|
|
50
|
+
|
|
51
|
+
Full format in [storage.md](storage.md#session-files-20).
|
|
59
52
|
|
|
60
53
|
### Exchange deletion (soft UI + hard raw)
|
|
61
54
|
|
|
62
55
|
`deleteExchange(sessionId, userOrdinal, archiveCount)` lets the user drop a single exchange (one user turn + all its responses) from the admin chat. It treats the two streams **asymmetrically** — the point is to free LLM context while keeping a visible audit trail:
|
|
63
56
|
|
|
64
57
|
- **`messages` (UI log): soft delete.** The target user message and every following message up to (not including) the next user message get `deleted: true`. The array length is unchanged, so `messageCount` semantics hold and the turn stays rendered — greyed out in the admin. There is no undo; a deleted exchange hides its own Delete button.
|
|
65
|
-
- **`rawMessages` (LLM-facing): physical delete
|
|
58
|
+
- **`rawMessages` (LLM-facing): physical delete.** The row's own block (located by its stamp, below), plus the reply through to the next user-turn start when the row owns it, so `tool_use` / `tool_result` pairs are never split (an orphaned `tool_result` would make every subsequent API call error out). `repairConversationMessages` then cleans the seam.
|
|
59
|
+
|
|
60
|
+
**Turn matching by stamp.** A main user row carries `stamp` — its [arrival stamp](#message-queue-and-drain), the same value its raw text block starts with (`[<stamp>] …`). `deleteRawBlock` finds the one raw user text block starting with it and removes it together with the image blocks / `[image removed: …]` placeholders right before it (a message's images precede its text). If a later user text block follows in the same raw message, that block is a later row (a queue batch, a Stop fold, `run()`'s merge into a dangling turn) and owns the reply — only this row's blocks go. Otherwise the reply goes too, through to the next turn start, and whatever the message keeps (tool results it answers — a soft interrupt lands the new input in the batch's results message — or earlier rows' blocks) merges into the next turn's opening, results leading. So in a merged message deleting an earlier row keeps the reply, deleting the last row leaves the earlier rows as a dangling user turn (a legal state: the next input merges into it), deleting all of them drops the turn. A stamp held by two UI rows, or by two raw blocks, is refused (`'raw_unmatched'`) — one process never repeats a value; only a restart after the wall clock stepped back, or two processes (a cron `halo cli` run and the server) writing one session in the same millisecond, could.
|
|
61
|
+
|
|
62
|
+
**A stamped row raw doesn't hold is refused.** Every entry point passes the row's stamp on, so a stamped row with no block in raw never reached the model's history — a message queued while busy and lost to a restart before the drain, or a send that threw after the append. After the last summary that is `'raw_unmatched'`; before it, UI-only as above. It never falls back to the text match: with another turn of the same text in raw, the count would land on that turn and delete it silently.
|
|
66
63
|
|
|
67
|
-
**Turn matching.**
|
|
64
|
+
**Turn matching by text (rows from before stamps).** A row with no `stamp` (written before this field existed, or converted from 1.x — their raw turns either carry no stamp or one taken at turn start) is located the old way, **among old rows and old turns only**: the UI user text against each raw user turn's text, both normalized by `normalizeTurnText` (`session-manager.ts`), which undoes every known rewrite between the UI row and its raw copy: the leading `[<iso>] ` arrival stamp (`stripTurnStamp`), the `[channel: …]` tag, an agent message's `(from: session X)` prefix, `buildInput`'s `[注意:…]` image notes, `drainQueue`'s continue_task reset note and `[System @ …]` sibling-status suffix, and the UI's `[图片已保存: …]` markers. Duplicate prompts are told apart by their occurrence rank among the live unstamped user rows after the last summary, counted **from both ends** against the raw turns no stamped row owns (a turn holding a block that starts with some stamped row's `[<stamp>]` is left out) — the two must point at the same raw turn. So a stamped row, or its turn, never shifts the count of an old one. A raw turn-start holding more than one text block is several UI rows merged into one and matches none; `@scope` expansion and skill bodies are not undone by the normalizer, so those rows stay unmatched too. Both kinds disappear as compaction folds old turns away.
|
|
65
|
+
|
|
66
|
+
**UI and raw move together.** A turn after the last compaction summary must be located in raw, or the delete is **refused** with `'raw_unmatched'` and nothing changes (the WS layer sends an error frame with `code: 'raw_unmatched'`, see [ws.md](ws.md)) — a UI-only delete would leave the model with a turn the user thinks is gone. A turn **before** the last summary row is mostly gone from raw (raw keeps the summary), but `compactCut` keeps the last few raw messages whole and may cut mid-turn, so the last turn or two before the summary can still have a copy there: a stamped row whose block is still in raw is removed from raw too; otherwise (no block, or no stamp) the delete is UI-only (`'deleted'`, raw untouched). A summary row itself is refused (`'raw_unmatched'`): deleting raw's summary would drop the compacted history.
|
|
67
|
+
|
|
68
|
+
**Known limit: a message queued while busy.** Its UI row is appended the moment it arrives — mid-reply, cutting the streaming assistant row — while raw holds it in the next drained turn. So the UI's soft-delete span (row → next user row) and the raw span (block → next turn start) cover different replies at that seam. The stamp settles *which* row is deleted, not this boundary; it predates stamps and is no worse with them.
|
|
68
69
|
|
|
69
70
|
**Ordinal alignment (the sharp edge).** `userOrdinal` is the 0-based index of the target user turn, counted **excluding `taskId` (sub-agent) messages** — matching the admin's `isMainConversationMessage` filter, which is what the frontend counts on. Both the ordinal-locate loop and the duplicate-rank loop skip `taskId` messages; if they didn't, a sub-agent's injected user turn in the root log would drift the count and delete the wrong exchange.
|
|
70
71
|
|
|
71
|
-
**Stale anchors are refused (`'archived'`).** `userOrdinal` is positional over the log both sides can see, so it only means anything while client and server agree where the log *starts*. Archiving moves that start forward, and a chat panel open across the compact still counts from the pre-archive top — the same ordinal then maps onto a different turn on the server. The payload now carries the anchor: the client sends `archiveCount`, the archived-segment count its view was opened against. `deleteExchange`
|
|
72
|
+
**Stale anchors are refused (`'archived'`).** `userOrdinal` is positional over the log both sides can see, so it only means anything while client and server agree where the log *starts*. Archiving moves that start forward, and a chat panel open across the compact still counts from the pre-archive top — the same ordinal then maps onto a different turn on the server. The payload now carries the anchor: the client sends `archiveCount`, the archived-segment count its view was opened against. `deleteExchange` counts the committed segments on disk (`countArchiveSegments`, not memory — the segment files are the truth about what was archived) and refuses with `'archived'` only when the two differ; a session with archives can still be deleted from as long as the anchor matches. The WS layer surfaces the refusal as an error frame with `code: 'archived'` (see [ws.md](ws.md)); the admin renders the message verbatim, without the `Error:` prefix it adds to unexpected failures.
|
|
72
73
|
|
|
73
|
-
**Memory/disk sync.** A live session mutates `agent.messages` in place then `saveAgentState`; a cold session
|
|
74
|
+
**Memory/disk sync.** A live session mutates `agent.messages` in place then `saveAgentState`; a cold session's logs are read, edited and rewritten whole (atomic, through the same writers; a 1.x session is migrated first by that read). Rejected with `running` / `compacting` while a turn is in flight (mutating raw mid-turn would corrupt the in-flight conversation). Refresh is push-based: the active session gets a `state:snapshot`, other open sessions pick it up via the existing `.halo/sessions/` file watcher — no new WS message. Entry point: WS `exchange:delete` → `handler.ts:handleExchangeDelete` (see [ws.md](ws.md)).
|
|
74
75
|
|
|
75
76
|
## SessionManager
|
|
76
77
|
|
|
@@ -114,7 +115,7 @@ interface AgentSession {
|
|
|
114
115
|
toolCallLog: Array<{name, inputHash}> // loop detection
|
|
115
116
|
contextConfig: { maxTokens, compressAt }
|
|
116
117
|
isCompacting: boolean
|
|
117
|
-
foldAfterCompact:
|
|
118
|
+
foldAfterCompact: QueuedMessage[] | null // queue entries a Stop folded while a compact was in flight; clearCompacting lands them on the post-compact history
|
|
118
119
|
interruptRequested: boolean // soft-interrupt flag — abort at the next batch boundary
|
|
119
120
|
selfKick: boolean // armed by the built-in continue_task tool; drainQueue turns it into one synthetic resume turn (one-turn lifetime)
|
|
120
121
|
resumedAfterInterrupt: boolean // this turn was started by drainQueue after an interrupt — the only state in which continue_task arms selfKick
|
|
@@ -148,7 +149,7 @@ Hierarchical encoding: `root_id>child_segment>grandchild_segment`.
|
|
|
148
149
|
### Lifecycle
|
|
149
150
|
|
|
150
151
|
- **Lazy loading**: agent instances are released after each turn finishes
|
|
151
|
-
- **State persistence**: `agent.messages` (
|
|
152
|
+
- **State persistence**: `agent.messages` land in `{seg}.raw.jsonl` (appended), output + last activity in the row, via `saveAgentState` — on every `tool_call` event (the assistant message holding the tool_use is already in history, so the call is on disk *before* it runs), every `tool_result` and `usage` event, at the top of every loop iteration via agent-loop's `beforeCallModel` hook (after the previous tool_results were appended, right after `maybeAutoCompact`), after a successful compaction, after a change to an idle session's history (a queue fold landed by Stop or by the end of a manual `/compact`), and on release — the full list under [Session files](#session-files) (UI and raw stay in step). The raw log is written at every one of those points; the row's `output` / `last_activity_at` pair on the per-event ones (`tool_call` / `tool_result` / `usage`, and the `beforeCallModel` save before each model call — a sequential tool loop calls the model every second or two) is throttled to one write per `ROW_WRITE_THROTTLE_MS` (5s) per session, with a trailing write of the latest values (`session-state-store.ts`). Turn end / release, Stop, compaction (it saves itself, unthrottled), `deleteExchange` and shutdown (`saveRow`, a session mid-compact included) write it exactly, so only a hard kill can leave the row up to 5s behind the raw log; nothing orders by these columns (the list sorts by `updated_at`). Until 1.3.3 only the release write existed, so a restart / crash mid-turn lost the whole turn from the model's memory (user message + every tool call) while the UI log — written per event — still showed it all; the agent came back with no idea what it had just been asked to do. Saving on `tool_call` matters when the tool *is* the restart (self-upgrade → `systemctl restart`): without it the agent woke up seeing "installed, not restarted", restarted again, and looped. Only the **result** of the tool in flight at the moment of death is lost — repair synthesizes an `[interrupted]` tool_result for it on load. The UI log flushes on the same events.
|
|
152
153
|
- **Auto-report** (`tryReportToParent`): when `runSession` finishes and the session becomes idle (`promise = null`), the `runSession` finally-block sets `promise = null`, emits the terminal `complete` (root only), then calls `tryReportToParent` which checks:
|
|
153
154
|
1. `parentId !== null`
|
|
154
155
|
2. DB shows no active children
|
|
@@ -169,7 +170,15 @@ A sub-session whose process was killed mid-run never got its `stoppedAt` written
|
|
|
169
170
|
|
|
170
171
|
**Workspace-level gate (`.halo/runtime.lock`)**: `server.lock` ownership alone is not sufficient — two servers with different `HALO_HOME` (e.g. prod + dev) each hold their own `server.lock` yet can point at the *same* workspace directory, and one server's boot reconcile would batch-stop the other's actually-live sub-sessions (the incident this gate exists for). So the reconcile additionally requires `claimWorkspaceRuntime(workspaceRoot)` (`agents/workspace-runtime-lock.ts`) — a pid marker at `<workspace>/.halo/runtime.lock` with a liveness probe. Claim fails → skip reconcile and log a warning: **prefer missing a crash-orphan cleanup over stopping another process's live sessions**. `reconcileOrphansOnBoot: true` therefore means "reconcile if the workspace claim succeeds," not "always." Lock protocol details in [storage.md](storage.md#workspace-runtime-lock); known residual: when two servers both actively use one workspace long-term, the non-owner never reconciles — its own crash orphans stay un-cleaned until the owner restarts and takes over. The run-ledger eager boot loop below deals with the *un-opened* case for the same residual: a workspace whose runtime another live process owns is skipped before a SessionManager is even built, so the rows wait for a later boot instead of freezing behind a cached non-owner instance. A `HALO_BADGE=DEV` server never takes part (`config.server.ownsWorkspaceRuntimes`): its registry is built with `reconcileOrphansOnBoot: false` and it skips the eager loop, so it never claims or writes `runtime.lock` and never runs reconcile, goal or ledger sweeps. Its ledger is off as well. Channel route restore builds its SessionManager without a claim. Prod owns all of this outright. Costs: orphans from a dev crash wait for prod's next restart, and a prod restart stamps the sub-sessions dev is running in a shared workspace stopped.
|
|
171
172
|
|
|
172
|
-
**Shutdown** (`gracefulShutdown` in `index.ts`):
|
|
173
|
+
**Shutdown** (`gracefulShutdown` in `index.ts`): its first line is `beginShutdown()` (`src/shutdown.ts`, a process-level flag + `shutdownSignal`; the CLI / TUI never set it). From then on nothing new starts that the exit would cut off — runs are still not aborted or ended:
|
|
174
|
+
|
|
175
|
+
- `beforeCallModel` parks the turn (`parkUntilAborted`) — checked first (no compact, no save) and again after the auto-compact, since shutdown may begin while one runs — so no model call starts in the stop window; the run stays open (`promise` set, ledger row kept) for `landForShutdown` and the next boot's nudge. The park settles only when the turn's own signal aborts: a Stop / delete meanwhile still ends the run as usual (ledger row deleted, no nudge). An interrupt doesn't: a soft one (a message queued while busy) is checked after `beforeCallModel` returns, so it never wakes the park, and a hard one (`interruptSession`) aborts it but its follow-up turn parks again at its first call, keeping a ledger row — a park that never settled hung `stopSession`'s await, and a channel shutdown waiting on a `/goal pause` behind it, until systemd's SIGKILL. A `drainQueue` turn started meanwhile parks at its first call the same way.
|
|
176
|
+
- `maybeAutoCompact` returns at once (mid-turn and turn-end): the summarize call would be cut off by the exit; the next boot's turn compacts if the session is still over the threshold. A summarize call already in flight when shutdown begins is not cut short.
|
|
177
|
+
- `shell_exec` runs no new command: it answers `Error: command not run — the server is shutting down. Safe to re-issue after the restart if still needed.`
|
|
178
|
+
- A `shell_exec` killed by a signal halo didn't send (no timeout, no abort — under systemd `KillMode=control-group` the whole cgroup gets SIGTERM with halo) while shutdown is on answers `[Command cut off by a server restart — … Check before re-running it.]` plus `Output so far:` (stdout, then `STDERR:`), masked like any result, instead of a plain `Command failed`. The command's `close` can be handled before halo's own SIGTERM handler, so a signal kill waits up to 500ms for the flag before keeping the old text. A normal exit (zero or not) reads as before.
|
|
179
|
+
- halo kills no child itself: started without a supervisor that signals the group, a command can outlive the server, and the next boot's repair marks its call.
|
|
180
|
+
|
|
181
|
+
For every loaded workspace, `sm.landForShutdown()` lands the UI logs not yet persisted (a dirty root plus every live sub-session log — the pending 500ms debounces would die with the process) and the raw logs of the running sessions (see [Session files](#session-files)); it runs again after the observability flush, since the runs keep going through the awaits — right before that last call, the shutdown waits for in-flight `shell_exec` calls to settle (1s at most, so one ignoring SIGTERM can't hold up the exit; immediate under systemd), so the cut-off results are in the history it writes. Only after that last write does `releaseWorkspaceRuntime` unlink `.halo/runtime.lock` — only when it holds *this* process's pid — and the AgentCore lease stop. A crash / SIGKILL leaves the file behind and takes the "pid dead → next boot takes over" path, so the release is an optimisation (a recycled pid can't read as a live holder), never a correctness dependency. The desktop app's `before-quit` (POSIX) holds the quit until the server process has actually exited (3s hard cap), so `killServer`'s SIGKILL fallback for a wedged server still runs instead of being dropped with the Electron process — otherwise a stuck server keeps the port and `server.lock`.
|
|
173
182
|
|
|
174
183
|
The constructor chain is now `reconcile orphans → sweepActiveGoals (retained legacy) → sweepInterruptedRuns` — all three gated on the same `reconcileOrphansOnBoot` + `.halo/runtime.lock` claim, so a workspace that fails the claim runs none of them.
|
|
175
184
|
|
|
@@ -179,7 +188,10 @@ The orphan reconcile above fixes the *children*'s `stoppedAt`; nobody told the *
|
|
|
179
188
|
|
|
180
189
|
**Ledger** (`db/runs-db.ts`): a global `~/.halo/global/runs.db` (same singleton pattern as `evo.db` / `cron.db` — `setRunsDb`/`getRunsDb`, WAL), one table `running_sessions(workspace TEXT, session_id TEXT, started_at INTEGER, PK(workspace, session_id))`. `runSession` inserts the id on entry and deletes it in its `finally`, so the steady state is an **empty table** — whatever is still there at boot is exactly the set of runs the previous process died in the middle of; no pid, no liveness probe needed. Only the long-lived server writes: `SessionManager.ledgerEnabled = opts.reconcileOrphansOnBoot === true`, the same flag that gates the orphan reconcile — cli / cron / TUI / evo-wrapper runs end with their terminal and never enter the table. Writes go through a private `ledgerWrite` wrapper that try/catches and only `console.warn('[RunLedger] ledger write failed …')` on failure: a sqlite hiccup (disk full, EIO) must not drop a message or strand a session at `promise = null` without a `complete` — worst case is one stale/missing row, i.e. one spurious or missed nudge at next boot.
|
|
181
190
|
|
|
182
|
-
**Sweep** (`sweepInterruptedRuns`, `agents/run-ledger.ts`): runs from the SessionManager constructor right after the retained legacy `sweepActiveGoals`, under the same ownership gate. `drainRunning(workspace)` reads and deletes that workspace's rows in one transaction **before** any nudge goes out — the invariant being that a nudge's own re-dispatch inserts a fresh row, which must never be mistaken for a leftover on some future sweep. Ids are grouped by root (`id.split('>')[0]`); each root gets one append-then-send nudge
|
|
191
|
+
**Sweep** (`sweepInterruptedRuns`, `agents/run-ledger.ts`): runs from the SessionManager constructor right after the retained legacy `sweepActiveGoals`, under the same ownership gate. `drainRunning(workspace)` reads and deletes that workspace's rows in one transaction **before** any nudge goes out — the invariant being that a nudge's own re-dispatch inserts a fresh row, which must never be mistaken for a leftover on some future sweep. Ids are grouped by root (`id.split('>')[0]`); each root gets one append-then-send nudge, worded by whether the root's **own** id is among the rows:
|
|
192
|
+
|
|
193
|
+
- **Root mid-run** (text starts `[System] The server restarted at <ISO> while you were mid-turn…`): about the root's *own* turn first — history up to the last completed tool call is intact, the in-flight call was cut off and its side effects may or may not have landed (check before repeating anything destructive), continue the task from there; don't start over, don't wait for input. Sub-agents come second (stopped-but-revivable via `query_session`; not listed — the root's own transcript / `session_list` has them). The first version talked only about sub-agents, so a root killed mid tool-loop went hunting for stopped children, found old ones, and declared "nothing was interrupted" instead of resuming.
|
|
194
|
+
- **Root idle, only sub-sessions mid-run** (text starts `[System] The server restarted at <ISO>. You were idle, but these sessions in your session tree were mid-run and were cut off…`): lists the cut-off ids of the root's tree — children and deeper (sorted, at most 10, then `(+N more)`) and says to re-dispatch with `query_session` if it was waiting on their reports, otherwise carry on. Sending the mid-turn text here sent an idle root looking for an in-flight tool call of its own that never existed. Skipped per root: row missing or `archivedAt` set; existing goal-mode bindings (a session with a `goal` column set, or a worker whose bound goal is still `running` — the retained `sweepActiveGoals` owns those nudges, unchanged); `cron-*` ids; `internal: true` agents (synchronous read of `agent.yaml`, since the sweep runs from a sync constructor). Principle: exactly one entry point gets woken per tree — a plain tree wakes its root, a `running` goal tree (existing binding) wakes its goal session, a cron tree wakes nobody (it waits for its next fire).
|
|
183
195
|
|
|
184
196
|
**Eager boot** (`index.ts`): rather than waiting for someone to open a workspace, right after constructing `SessionManagerRegistry` (before `bootChannels`) the server calls `listRunningWorkspaces()` and for each workspace with leftover rows whose `.halo` dir still exists: `claimWorkspaceRuntime(ws)` first — if another live server owns it, `continue` (rows stay for a later boot, and no SessionManager is built, since caching a non-owner SM would freeze "not owner" for the whole process lifetime) — otherwise `registry.getOrCreate(ws)`, which fires the same constructor chain (claim → reconcile → retained `sweepActiveGoals` → `sweepInterruptedRuns`). An interrupted root with nobody around is exactly the one that must nudge itself without a human opening the tab first.
|
|
185
197
|
|
|
@@ -191,18 +203,23 @@ The orphan reconcile above fixes the *children*'s `stoppedAt`; nobody told the *
|
|
|
191
203
|
|
|
192
204
|
> **History**: earlier builds ran **two** parallel queues — `messageQueue` for agent→agent (`query_session` / `interrupt_session` / auto-report) and `pendingUserMessages` for user→agent (channel sends during a busy turn), each with its own enqueue / drain / stop-clear / fold paths. They are now unified into a **single `messageQueue` + single `drainQueue` + single `runSession` loop**. Entries keep their meaningful differences (a `sourceSessionId` marks agent entries; user entries carry `images` and no source), but they share one queue and one drain path.
|
|
193
205
|
|
|
194
|
-
A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is set for agent→agent entries (drives the `(from: session X)` prefix and the sibling-status suffix) and absent for user messages; `images` is the user multimodal payload (agent entries have none).
|
|
206
|
+
A `QueuedMessage` is `{ text, sourceSessionId?, images?, stamp? }`: `sourceSessionId` is set for agent→agent entries (drives the `(from: session X)` prefix and the sibling-status suffix) and absent for user messages; `images` is the user multimodal payload (agent entries have none); `stamp` is the entry's arrival stamp (its UI row's turn key, see below).
|
|
195
207
|
|
|
196
208
|
**runSession loop** (`runSession(sessionId, message)`, `message: string | ContentBlock[]`): runs the opening turn when there is one, then drains. An empty **string** message means "the work is already in `messageQueue`" (the `querySession` idle path) — it skips the opening turn and goes straight to drain. The per-turn reset (fresh `toolCallLog` / `warnedToolHashes`, `interruptRequested = false`) runs before the opening turn; `drainQueue` repeats the same reset before each merged batch so a stale interrupt flag never leaks across turns. Server-only: right before `session.promise = runFn()` it inserts the session id into the run ledger, and in the `finally` (right after `session.promise = null`, before emitting `complete`) it deletes it — see [Run ledger](#run-ledger--restart-nudge-for-interrupted-roots-haloglobalrunsdb) above.
|
|
197
209
|
|
|
198
210
|
**Observability.** `runAgentTurn` is wrapped by `beginTurn` / `endTurn` from `observability/genai-spans.ts` — one `invoke_agent` span per turn, with `chat` / `execute_tool` child spans created from the `onAgentEvent` hook inside the event loop; see [design/observability.md](observability.md).
|
|
199
211
|
|
|
200
|
-
**Arrival stamp.**
|
|
212
|
+
**Arrival stamp.** Every user message the model sees carries `[<ISO-8601 UTC>] ` (e.g. `[2026-09-12T15:17:44.153Z] `) — the moment it **arrived**, which is also its turn key. `nextTurnStamp()` (`sessions/session-store.ts`) is a monotonic ms clock (`max(now, last + 1)`), so no two messages of one process share a value. The value is taken where the UI row is: `appendUserMessage` returns it, the caller hands it to `sendUserMessage` / `enqueueUserMessage` (the queue entry keeps it); `querySession` and the continue_task kick stamp their own row and entry. The model has no clock; without this it couldn't tell a reply that came two days later from one that came instantly, or how long a sub-agent report took to land.
|
|
213
|
+
- **Opening turn** (`runSession(id, message, stamp)` → `runAgentTurn`): the first text block gets the prefix, an image-only input gets a stamp-only text block unshifted. A message with no stamp (a sub-session's opening brief, a caller that passes none) is stamped there.
|
|
214
|
+
- **Drained batch** (`batchInput`): one block group per message — its images, then `[<stamp>] <text>` (an agent message: `[<stamp>] (from: session X)\n…`, stamp outermost) — and the sibling-status suffix / continue_task reset note close the last text block. Before, a batch was one joined text block under one stamp taken at drain time; the OpenAI-format runtimes join a user message's text blocks with `\n`.
|
|
215
|
+
- **Stop fold** (`foldIntoAgentMessages`): one stamped text block per queued message, same shape (images are not folded).
|
|
216
|
+
|
|
217
|
+
Stamped **once, before the retry loop**, so every attempt (and the [multimodal 4xx degrade](#multimodal-4xx-degrade) rebuild) reuses the identical input. Only `agent.messages` carries the prefix: the UI `type:'user'` row keeps the raw text and stores the value as `stamp` ([Turn matching](#exchange-deletion-soft-ui--hard-raw)). The self-compact instruction, the `[Conversation Summary]` injection and `tool_result` messages are never stamped.
|
|
201
218
|
|
|
202
219
|
**Final-text flag.** The agent loop marks each text block `final` when the model call ended for a reason other than `tool_use` (i.e. it is the turn's wrap-up, not the filler emitted before a tool call), and `runAgentTurn`'s `case 'text'` forwards it on the `stream` event as `AgentSessionEvent.final`. Block-send consumers gate on it: the channel responders (wechat / telegram / slack / feishu / wecom) append only `final` text, and `halo cli` prints the last root turn's `final` text as its stdout (fallback: that turn's full text). `finalOutput` (used by `tryReportToParent`) is the same accumulation on the server side. Streaming providers additionally yield `text_delta` / `thinking_delta` events during the model call (forwarded as `stream_delta` / `thinking_delta`, UI-only); they never touch `output` / `finalOutput` — those only ever see the whole `text` event, which follows every completed call exactly as before. The one place the loop itself keeps the text deltas is the interrupted-stream landing (see [Partial streamed text lands](#conversation-repair)): a call cancelled mid-stream pushes what it had streamed as a marked assistant turn so the model's history matches what the UI showed.
|
|
203
220
|
|
|
204
221
|
**drainQueue** folds the **whole queue** into ONE merged follow-up turn per round, re-checking after each round (a fresh interrupt or a sibling's report can land mid-drain):
|
|
205
|
-
- The batch is `splice(0)`'d
|
|
222
|
+
- The batch is `splice(0)`'d and built by `batchInput`: one stamped block group per entry (agent entries keep a `(from: session X)` prefix after the stamp), each entry's `images` ahead of its own text.
|
|
206
223
|
- The `siblingStatusSuffix` is appended **only** when the batch carries at least one agent report (`batch.some(sourceSessionId)`) — a pure-user batch must not trigger "all sub-agents completed" noise.
|
|
207
224
|
- **`queued_message` is emitted here and only here** — once per merged batch, root only (it opens a fresh streaming assistant bubble; the text is cosmetic, downstream reads only `{chat:followup, agentName}`). `querySession`'s enqueue path emits just a `type:'user'` trace, never `queued_message`, so N reports folding into one turn produce **one** bubble, not N ghost bubbles. `halo cli` also relies on this event as its "new root turn" boundary to reset its per-turn text buffers (no `complete` separates the opening turn from the first drained turn), so it must stay unconditional.
|
|
208
225
|
|
|
@@ -224,7 +241,7 @@ A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is
|
|
|
224
241
|
- **Deferred interim** (`sendDeferredInterim`, in `runSession`'s finally right before `tryReportToParent` / `deliverRelayReport`): a session waiting on its own sub-agents is asked by its caller (`relay_send` / the parent's `query_session`) and answers, and the run ends with a child still running — both end-of-run reports skip (subtree not quiet), and the child's later report wakes a new run whose first turn resets `output`, so the answer reached no one. The run's last turn (`RunLastTurn { batch, waitingOnChildren }`, refreshed before the opening turn and every drained batch) sends an interim through `sendInterimReport` when it answered the owed party (`interimDoor`) **while a child was already running at turn start** (`waitingOnChildren` — door first, db lookup only past it, so local chat / child reports / goal rounds / kicks never query; turn *start*, because the dispatch turn starts its child mid-turn and owes the final report, not an interim), `finalOutput !== ''`, no `turnError`, and the subtree is still busy at run end (`listActiveChildren`, the same db gate the two reports read synchronously right after: children running → they skip and this sends; none → they report the turn as final and this doesn't). `reply_to` is kept, so the final report still fires once. Never overlaps the drain path: a turn another turn follows is never the last.
|
|
225
242
|
- **Stop / delete / archive never resurrect**: `stopSession` and `stopUserSession` clear `selfKick` next to `interruptRequested`; `deleteSession` (root + children) and `archiveSession` clear it right after nulling `abortController` and *before* awaiting `session.promise` (the aborted turn's drain still runs inside that promise and would otherwise fire a real kick turn); and `requestSelfKick` returns `no_turn` when `session.abortController === null` — Stop nulls it *before* the abort lands, so a tool callback that completes after a Stop can't re-arm the flag. `enqueueUserMessage` (admin WS) mirrors `sendUserMessage` and does **not** set `interruptRequested` while a compact runs outside a live model loop (manual `/compact`, or the turn-end auto-compact — `abortController` already null) — there is no live turn, and the drain would otherwise snapshot it into `resumedAfterInterrupt` for a turn that interrupted nothing. A mid-turn auto-compact (`beforeCallModel`, `abortController` still set) does sit inside a live turn, so the flag is set and the turn yields at that same boundary once the compact returns, instead of running to its natural end with the message still queued.
|
|
226
243
|
|
|
227
|
-
**Stop preserves, archive discards.** Both `stopSession` and `stopUserSession` (the user Stop button) fold the **whole** `messageQueue` into `agent.messages` as a user turn
|
|
244
|
+
**Stop preserves, archive discards.** Both `stopSession` and `stopUserSession` (the user Stop button) fold the **whole** `messageQueue` into `agent.messages` as a user turn — one stamped text block per entry (agent entries with their `(from: session X)` prefix) — **before** aborting, then `repairConversationMessages` — a stop **parks** the work, it never drops a queued message (mirrors interrupt, which also never loses one). While a compact is in flight the fold is parked in `foldAfterCompact` and lands when the compact ends (see [Compaction paths](#compaction-paths)). `archiveSession`, by contrast, clears the queue without folding — archiving a subtree is a deliberate discard.
|
|
228
245
|
|
|
229
246
|
#### Process-group kill on abort
|
|
230
247
|
|
|
@@ -244,7 +261,7 @@ Two-level dispatch:
|
|
|
244
261
|
1. **Per-session-tree listener** — WS handler calls `registerEventListener(rootSessionId, handler)`. Any event in the tree routes to the root via `findRootSessionId(sessionId)` = `sessionId.split('>')[0]`.
|
|
245
262
|
2. **Global fallback** — the `eventHandler` field, used when no tree listener matches.
|
|
246
263
|
|
|
247
|
-
`emitEvent` captures the turnId *before* reducing the event but hands listeners the state *after* — sub-agent events carry their own `taskId`/`currentTurnId`, so a single post-reduce turnId would collapse every sub-agent block into one bubble. Persistence is split: `
|
|
264
|
+
`emitEvent` captures the turnId *before* reducing the event but hands listeners the state *after* — sub-agent events carry their own `taskId`/`currentTurnId`, so a single post-reduce turnId would collapse every sub-agent block into one bubble. Persistence is split: the events the raw log saves on (`tool_call`, `tool_result`, `usage`), plus `complete` (which also broadcasts `session:changed` so admin lists re-fetch), a sub's `agent_done`, `error` and `call_discarded`, flush synchronously; `user`, `system` and `context` rows take the 500ms debounce. Characterized in `test/session-ui-store.test.ts`.
|
|
248
265
|
|
|
249
266
|
Two event-field notes (`agents/agent-events.ts`):
|
|
250
267
|
- **`agent_start` carries `text` + optional `fullText`.** `text` is a 200-char preview for parent-side rendering (the `agent:start` WS message, in-flight panel); `fullText` is the un-truncated task brief (`system_prompt_context` + message, without the `[Session id]` assembly) that `ui-log-builder.initSubSessionLog` uses to seed the sub-session UI log's opening user message — so the child's log shows the whole brief, not a cut-off preview.
|
|
@@ -256,7 +273,7 @@ The five session tools that take an existing `session_id` — `query_session`, `
|
|
|
256
273
|
|
|
257
274
|
Within the tree, `stop_session` / `archive_session` (and `relay_stop` aimed at the caller's own workspace) also refuse the caller's **own session or an ancestor** (`callerId === targetId || callerId.startsWith(targetId + '>')` — `isSelfOrAncestor` in `session-tools.ts`). Both cascades await every running turn in the target's subtree, and that subtree contains the turn running the tool call, so the await never settled and the turn hung until restart. `interrupt_session` on self or an ancestor does not hang — `interruptSession` is fire-and-forget — and stays allowed. (`relay_send` / `relay_interrupt` refuse self / an ancestor in the own workspace too, for a different reason: the relay report would land back in the caller's own tree — see [relay.md](relay.md).)
|
|
258
275
|
|
|
259
|
-
Why it's needed: one `SessionManager` per workspace holds **every** user's/channel's session trees (root ids are prefixed `web_<acct>_…` / `tg_<userId>_…` / …). Session ids are hierarchical strings and the full id is enumerable — a child knows its own id (and thus its root, the left-most segment), and any agent with `file_read` can
|
|
276
|
+
Why it's needed: one `SessionManager` per workspace holds **every** user's/channel's session trees (root ids are prefixed `web_<acct>_…` / `tg_<userId>_…` / …). Session ids are hierarchical strings and the full id is enumerable — a child knows its own id (and thus its root, the left-most segment), and any agent with `file_read` can list `.halo/sessions/<agentId>/` and read the workspace's `halo.db`, which hold every session's id + parent. Without the gate, a prompt-injected agent on one tree could enumerate a foreign root id and `archive` (irreversible cascade), `stop` (DoS), or `get_session_output` (read transcript) another user's tree. This is the only by-id session entrypoint; the user-facing paths (`/switch`, `/session info`/`/tree`, `visibleSessions`) already enforce the same prefix scoping.
|
|
260
277
|
|
|
261
278
|
The gate lives at the **tool callback layer** (`agents/session-tools.ts`), not inside the SessionManager methods — those same methods are also reached by already-authorized user paths and by the internal auto-report (`tryReportToParent` → `querySession(parentId, …)`, always in-tree), so an in-method check would break legitimate callers. `start_session` / `query_agent` gate at the same callback layer (on agent_id + team), so all delegation authorization sits in one place. Covered by `test/session-agent-builder.test.ts` (cross-tree refused without touching the target; same-tree passes through).
|
|
262
279
|
|
|
@@ -270,7 +287,7 @@ Because every event reduces into the **root's** UIState (a sub-agent's stream /
|
|
|
270
287
|
|
|
271
288
|
An idle-sweep candidate is refused eviction while `hasActiveWorkInTree(rootId)` is true — any session in the root's tree (root or `root>…` descendants) with an in-flight `promise` or `isCompacting` exempts the **whole tree**, because a fire-and-forget sub-agent's live stream/tool buffers hang off the *root's* UIState even after the root itself released between turns. This was chosen over the two eviction points that looked simpler but fail on verified facts: dropping in `releaseSession` (turn end) would force an admin-open or mid-conversation channel session to re-read its full file from disk on every next message — hot-path I/O; "drop when no active subscribers" would exempt exactly the leak's dominant source, since a channel account's `InboundBridge` listener subscribes permanently.
|
|
272
289
|
|
|
273
|
-
`dropUIState` flushes a pending debounced persist — the root's and its tree's pending sub-session writes — **before** evicting — otherwise an immediate rehydration would read the shorter on-disk file, and that rehydrated state's next persist would overwrite the orphaned timer's later, complete write. After eviction, the next access (`ensureUIState`) rehydrates from disk exactly as it already does for a cold session — same code path, no special-casing.
|
|
290
|
+
`dropUIState` flushes a pending debounced persist — the root's and its tree's pending sub-session writes — **before** evicting — otherwise an immediate rehydration would read the shorter on-disk file, and that rehydrated state's next persist would overwrite the orphaned timer's later, complete write. After eviction, the next access (`ensureUIState`) rehydrates from disk exactly as it already does for a cold session — same code path, no special-casing. It also drops the dropped logs' writer baselines in `session-log.ts` (root + sub logs), so that cache doesn't grow with every session ever loaded; the rehydrating load re-primes it. Raw baselines stay — the AgentSession can outlive the UI state.
|
|
274
291
|
|
|
275
292
|
### SQLite metadata
|
|
276
293
|
|
|
@@ -290,16 +307,18 @@ Table `agent_sessions` holds metadata only (no runtime state):
|
|
|
290
307
|
| archived_at | INTEGER | null = not archived |
|
|
291
308
|
| goal / goal_session_id | TEXT | Retained legacy columns (goal mode binding JSON / back-pointer); existing bindings are unchanged and still honored |
|
|
292
309
|
| reply_to | TEXT | Relay: JSON `{ workspace, sessionId }` of the caller in another workspace, set by `relay_send`, cleared when the report is delivered — see [relay.md](relay.md) |
|
|
293
|
-
| title / exchange_count / context_tokens / total_output_tokens | TEXT / INTEGER | List-visible metadata
|
|
310
|
+
| title / exchange_count / context_tokens / total_output_tokens | TEXT / INTEGER | List-visible metadata — their only home (the log files carry none); `exchange_count` null = row from before 2.0 whose values still sit in a 1.x file |
|
|
311
|
+
| output / last_activity_at | TEXT / TEXT | Latest turn's accumulated text and ISO time of its last text / tool event (null if none), written by `saveAgentState` — mid-turn (per event and before each model call) at most once per 5s per session (trailing write), exact at turn end / release / Stop / compaction / shutdown — read by `get_session_output` for released sessions |
|
|
294
312
|
|
|
295
313
|
Status is derived from memory (`promise !== null`) — not stored.
|
|
296
314
|
|
|
297
|
-
**The four
|
|
315
|
+
**The four list columns** let the session list render rows without opening any session file. Invariants:
|
|
298
316
|
|
|
299
|
-
- **One write seam.** `
|
|
300
|
-
- **Idempotent, and never touches `updated_at`.** `
|
|
301
|
-
- **`exchange_count` is a lifetime count
|
|
302
|
-
- **
|
|
317
|
+
- **One write seam.** `SessionManager.persistUILog` — the single funnel every UI-log persist goes through — applies the write's metadata with `updateSessionMeta()`: the title only while the row has none (sticky), `exchange_count += ` the main user rows that write appended (0 for an archive rewrite, which only moves rows out), and the token counts. Besides it only the PATCH rename path writes `title`.
|
|
318
|
+
- **Idempotent, and never touches `updated_at`.** `updateSessionMeta` selects first and skips the UPDATE when nothing changes — a session persists every 500ms mid-turn; a missing row is a no-op (sub-sessions of a deleted tree). `updateSessionOutput` (the `output` / `last_activity_at` pair, on raw saves — throttled to 5s mid-turn, see [Lifecycle](#lifecycle)) is one UPDATE guarded by `IS NOT`. Neither touches `updated_at` — that column drives list ordering and "last activity".
|
|
319
|
+
- **`exchange_count` is a lifetime count**: new rows start at 0; archiving moves rows out without touching it.
|
|
320
|
+
- **Rows from 1.x** have `exchange_count` null. The list route, `getSessionTitle` (channel `/list`, `/tree`) and `GET /sessions/logs/:id` probe that session's UI log once, which migrates a 1.x session and fills the row's null columns from its header (`countMainUserMessages + archivedUserCount` for the count), then read the row again. A row whose session has no files at all stays null and pays one `existsSync` per probe.
|
|
321
|
+
- **Internal sessions** use the same columns in the global `internal-sessions/halo.db` — their only home. Every metadata reader (the list's `SessionInfo` projection, `GET /sessions/logs/:id`, `getSessionTitle`) and the PATCH rename resolve it through `sessionMetaDb()` (`sessions/session-store.ts`; `SessionManager.metaDb(agentId)` wraps it); the workspace row's metadata columns are never updated after creation and never read.
|
|
303
322
|
|
|
304
323
|
## Conversation repair
|
|
305
324
|
|
|
@@ -376,14 +395,14 @@ Frontend network issues don't affect the backend:
|
|
|
376
395
|
| Account-level error — **`httpStatus` 401 / 402 / 403** when a status is available; keyword match (insufficient balance / suspended / invalid key / unauthorized / authentication) only when none is | Unrecoverable — report to user, **no** retry |
|
|
377
396
|
| Rate limiting / throttling — `err.name === 'ThrottlingException'`, `httpStatus 429`, or keyword (`throttl` / `rate limit` / `ServiceUnavailableException`) | Exponential backoff (`2s * 2^attempt` + jitter, capped at 60s: 2s/4s/8s/16s…), retry |
|
|
378
397
|
| **Transient server-side error (5xx / timeout)** | Same exponential backoff as throttling — see [Transient server-error classification](#transient-server-error-classification) below |
|
|
379
|
-
| Transient transport error (`fetch failed`, `ECONNRESET`, headers timeout,
|
|
398
|
+
| Transient transport error (`fetch failed`, `ECONNRESET`, headers timeout, …; a stream that ends before its end events, `AnthropicStreamTruncated` / `OpenAIStreamTruncated`) | Short backoff (`1s * 2^attempt` + jitter), retry |
|
|
380
399
|
| Corrupted messages (`tool_use ids without tool_result`) | Repair + retry |
|
|
381
400
|
| **4xx multimodal rejection** (`Multimodal data is corrupted` / `Could not process image`) | Replace all image blocks in history with text placeholders, persist, retry — see [Multimodal 4xx degrade](#multimodal-4xx-degrade) below |
|
|
382
401
|
| Unrecoverable error | Report to user, stop |
|
|
383
402
|
|
|
384
403
|
**Refusal stop.** `stop_reason: "refusal"` (Anthropic models on Bedrock invoke + generic anthropic) is an HTTP 200 with `stop_details {category, explanation}` — not an error, so it never enters the retry matrix above. `agent-loop.ts` handles it explicitly: no assistant message is pushed, any partial `tool_use` is **not** executed, the loop ends; `session-manager.ts` emits a `system` event `⚠️ [<agent>] Model declined to respond (<category>): <explanation>` suggesting `/new`. Previously it fell through the `end_turn` path and looked like a silent stall.
|
|
385
404
|
|
|
386
|
-
**Classification order: structured signal first, message text last.** The interrupt branch is decided by the attempt's own `AbortController` signal (every interrupt path we own goes through `abortReason()` on it), never by the words `cancelled` / `aborted` in the message — an upstream error body can legitimately read `"The operation was aborted due to …"` (Bedrock), and matching that swallowed a real failure as a user interrupt: silent `break`, no retry, no error event, an empty reply. Likewise the account-level branch trusts `httpStatus` when one was recovered: 401/402/403 is terminal, any *other* status is not, whatever the body says — an OpenAI-compatible 503 whose body reads `"authentication service temporarily unavailable"` used to be classified as a dead key and never retried. The keyword list is only consulted when no status could be extracted. Pinned by `turn-retry-idempotent.test.ts` (
|
|
405
|
+
**Classification order: structured signal first, message text last.** The interrupt branch is decided by the attempt's own `AbortController` signal (every interrupt path we own goes through `abortReason()` on it), never by the words `cancelled` / `aborted` in the message — an upstream error body can legitimately read `"The operation was aborted due to …"` (Bedrock), and matching that swallowed a real failure as a user interrupt: silent `break`, no retry, no error event, an empty reply. Likewise the account-level branch trusts `httpStatus` when one was recovered: 401/402/403 is terminal, any *other* status is not, whatever the body says — an OpenAI-compatible 503 whose body reads `"authentication service temporarily unavailable"` used to be classified as a dead key and never retried. The keyword list is only consulted when no status could be extracted. Pinned by `turn-retry-idempotent.test.ts` (seven classification cases).
|
|
387
406
|
|
|
388
407
|
**Abort reasons are normalized through the `abortReason()` helper** — all three abort call sites (graceful interrupt after `tool_result`, `interruptSession`'s hard interrupt, `stopSession`'s stop) wrap the reason string in `new DOMException(reason, 'AbortError')` instead of passing it raw. Node 22 gotcha: `fetch` rejects with the abort reason **as-is**, so `controller.abort('interrupt')` surfaced the bare string `'interrupt'` (not an Error, `errName === ''`) in `runAgentTurn`'s catch — it missed the AbortError branch and logged a fake "Unrecoverable: interrupt" error. Contract pinned by a queue-semantics test.
|
|
389
408
|
|
|
@@ -414,6 +433,8 @@ Throttling is checked the same way one branch earlier — structured `Throttling
|
|
|
414
433
|
|
|
415
434
|
Backoff is identical to throttling: `2s * 2^attempt` + up to 1s jitter, capped at 60s, for up to `config.agent.maxRetries` (5) attempts.
|
|
416
435
|
|
|
436
|
+
**A stream that ends early without an error is network-class.** `AnthropicStreamAccumulator.finish()` (`agents/anthropic-stream.ts` — Bedrock, generic anthropic, MiniMax, Qwen) throws `AnthropicStreamTruncated` when the stream ended cleanly but neither a `message_delta` with a `stop_reason` nor a `message_stop` arrived (seen on Bedrock: minutes of thinking, then a clean end, out=0). Both events must be missing — a compatible provider may send only one of them. `ChatCompletionAccumulator.finish()` (`agents/openai-chat-stream.ts` — openai / deepseek / kimi / zhipu / doubao / hunyuan / gemini) throws `OpenAIStreamTruncated` when no `finish_reason` arrived, or one of DeepSeek's server-side cut-offs, `insufficient_system_resource` (lack of inference resources) or `aborted` (generation interrupted — our own aborts go through the signal, never a `finish_reason`); every live-probed provider sends one on its last choices chunk, before the usage chunk and `[DONE]`. `classifyModelError` maps both to `network` (`model-error.ts` branch 3c), so the turn gets the short backoff instead of ending as an empty `end_turn` reply. An abort still surfaces as an AbortError: the callers check their signal before `finish()`.
|
|
437
|
+
|
|
417
438
|
**Why this matters (original pain point)**: Bedrock returns a generic 500 with the message `"… is unable to process your request"` — no `Throttling`/`Overloaded`/`rate limit` keyword. The old logic, which classified retryable errors only by message-string keywords, matched nothing and let the turn die on attempt 1. Keying the retry on the **structured** `errName` / `httpStatus` instead of substrings is what makes the 5xx/timeout retry fire across every provider, Bedrock included.
|
|
418
439
|
|
|
419
440
|
## Compaction paths
|
|
@@ -466,35 +487,33 @@ The per-image side is at the entry points: the admin upload path re-encodes ever
|
|
|
466
487
|
|
|
467
488
|
Files: `packages/server/src/sessions/session-archive.ts` (segment IO) · `agents/session-ui-store.ts` `archiveOldMessages` (the write) · `routes/session-archive.ts` (the read)
|
|
468
489
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
**Trigger: file size.** A compact archives only when the active `<seg>.json` is larger than `ARCHIVE_SIZE_THRESHOLD = 3 MB` (one `statSync`; a missing file counts as 0, so the check is also the "nothing written yet" guard). Below it, `archiveOldMessages` returns 0 and touches nothing, however many exchanges have piled up.
|
|
490
|
+
Raw history has compaction; the UI log had nothing, so it only ever grew (a long-lived session measured 6.9 MB of UI messages out of a 7.4 MB file). Archiving moves the older exchanges out to `<seg>.ui.arch.<N>.<h>.jsonl.gz` beside the active `<seg>.ui.jsonl`, keeping the newest exchange in place. Raw is never archived.
|
|
472
491
|
|
|
473
|
-
|
|
492
|
+
**When: once per turn end.** The UIState reducer calls `archiveOldMessages` right after the turn's final flush — a root on `complete` (not a `batchBoundary`), a sub-session on `agent_done` (before its in-memory log is dropped). Never mid-turn: `selfCompactSession` no longer archives (it used to, and a mid-turn compact then rewrote the file the turn was still appending to). At a turn end there is no in-flight assistant, so a later `patch` record can only target kept rows — patches never cross into a segment. Cost: mid-turn the active file can exceed the threshold by up to the current turn.
|
|
474
493
|
|
|
475
|
-
**
|
|
494
|
+
**Trigger: file size.** It archives only when the active `ui.jsonl` is larger than `ARCHIVE_SIZE_THRESHOLD = 5 MB` — compared against the writer's own byte count (`cachedUILogSize`, no stat). Below it, `archiveOldMessages` returns 0 and touches nothing, however many exchanges have piled up.
|
|
476
495
|
|
|
477
|
-
|
|
496
|
+
Bytes, not an exchange count, because bytes are the actual problem — file size is what makes a log slow to read, parse and ship — and per-exchange size varies by ~three orders of magnitude (a bare "yes" versus a turn carrying several 50 KB tool results), so a count is a badly distorted proxy. It is also self-limiting: after an archive the file restarts at one exchange, so it must grow another 5 MB of real content before the next segment, which bounds segment count by total bytes written. 5 MB, up from 1.x's 3 MB, because a write is now an append: a larger active file no longer costs anything per event, only per open. No relation to `config.compact.keep_messages`, and no setting: one constant, tuned once (largest single turn measured: 3.3 MB).
|
|
478
497
|
|
|
479
|
-
**
|
|
498
|
+
**Split point.** Over the threshold, `archiveSplitIndex(messages, 1)` keeps only the **newest main user exchange** (`role === 'user'` without `taskId` — the same subset the admin renders and `deleteExchange` counts ordinals over) and cuts at its start, so a turn's responses are never split from their user message. A **compaction summary is not a turn start**: it is a user row the compactors emit mid-turn, flagged `compactSummary: true` (rows from before the flag are recognised by their `[Conversation Summary — ` prefix), and the split skips it — the summary stays in the active file with the turn it summarises. A log holding a single exchange yields cut 0 and is left alone: an oversized *single* exchange is deliberately not archived.
|
|
480
499
|
|
|
481
|
-
**
|
|
500
|
+
**Sub-sessions archive their own log.** A sub-session's UI log lives in its root's `UIState` (`subSessionLogs`, keyed by the full `root>…` id), but it is written to the sub's **own** file and segments (`fileSegment(sessionId)` — the id's leaf segment), never the parent's. Archiving a sub therefore never truncates the parent's log or touches the parent's file. The split rule is the same: every user row in a sub log (the brief, a `query_session` follow-up, a child's auto-report) is written without `taskId`, so each one opens an exchange there just as in a root log. A root uses `ensureUIState`; a sub only **peeks** the in-memory log (`agent_done` runs the archive before dropping it), so a missing sub log means nothing live to archive. Segments of a sub are served by the same route under the sub's full, URL-encoded id.
|
|
482
501
|
|
|
483
|
-
**Crash consistency —
|
|
502
|
+
**Crash consistency — no stored counter:**
|
|
484
503
|
|
|
485
|
-
1. **Segment first, active file second.** `writeArchiveSegment(n)` writes
|
|
486
|
-
2.
|
|
487
|
-
3. **
|
|
504
|
+
1. **Segment first, active file second.** `writeArchiveSegment(n)` writes the gzip (tmp + rename) first; only then is the active `ui.jsonl` atomically rewritten with the kept exchange. **That rename is the commit.** The reverse order could drop messages before their archive exists.
|
|
505
|
+
2. **The committed count is the number of segment files** (numbered 1..k without gaps), after one content check: the segment's name carries `h` = the first 12 hex of sha1(its first message id); if the highest segment's `h` matches the active file's first row id (`readFirstUIRowId`, the first few KB — every record leads with its id), the active rewrite never landed, so that segment is uncommitted and ignored. A crash between the two steps therefore reads as "no archive" — no duplicates, nothing lost — and the next archive picks the same `N` and replaces it (`writeArchiveSegment` removes every file numbered `>= n` first).
|
|
506
|
+
3. **Verify, else roll memory back.** `persistLog` swallows IO errors like every session write, so `archiveOldMessages` checks the commit rewrite returned a result; if not, the truncated in-memory `messageLog` is restored. Without that, the *next* ordinary persist would rewrite the file without the archived head while the segment still reads uncommitted — that, not the crash, is how messages would actually go missing.
|
|
488
507
|
|
|
489
|
-
Segments are immutable
|
|
508
|
+
Segments are immutable. Deletion is filesystem-driven: deleting a session lists its directory and removes every file of the session (`ui.jsonl`, `raw.jsonl`, `ui.arch.*`, leftover `.tmp.*`, 1.x `.json` / `arch.*.json.gz`) rather than trusting any count or in-memory map, so crash-orphaned segments go too.
|
|
490
509
|
|
|
491
|
-
**Read side (scroll-up loading).** The admin
|
|
510
|
+
**Read side (scroll-up loading).** The admin loads a session's whole active UI log and fetches segments only when the user scrolls to the top: `GET /api/sessions/logs/:id/archive/:n?projectId=` returns `{ messages }` for one whole segment, gunzipped server-side (`:id` is the encoded full id, so a sub-session's segments are reachable too). The anchor is `archiveCount`, delivered on the `state:snapshot` of **subscribe / reattach only** (one segment count per session open); per-turn snapshots and the post-`exchange:delete` snapshot deliberately omit it. The client walks `archiveCount` → 1 and stops at "no earlier messages"; pulled segments are cached (immutable, so never re-requested) and a failed fetch keeps the cursor so the same segment can be retried.
|
|
492
511
|
|
|
493
|
-
**The anchor moves up, never down.** `noteArchiveAnchor` no-ops for an equal or lower count (keeping per-turn snapshots — which omit the field, read as 0 — and ordinary re-subscribes idempotent), but a **higher** count re-anchors. Needed because a
|
|
512
|
+
**The anchor moves up, never down.** `noteArchiveAnchor` no-ops for an equal or lower count (keeping per-turn snapshots — which omit the field, read as 0 — and ordinary re-subscribes idempotent), but a **higher** count re-anchors. Needed because a turn that ends while the session is open can archive a new segment, and the next subscribe/reattach snapshot both carries the higher count *and* replaces the view with the shrunken active log: with a permanently pinned anchor those just-archived turns were in neither place — gone from the view and below a cursor counting down from the old anchor — showing a hole until the user reopened the session. Re-anchoring restarts the walk instead of extending it (prepends are oldest-first, so a newer segment can't splice onto an already-pulled tail); the re-pull costs no requests because `segmentCache` still holds them. Archived history renders collapsed and **read-only** — no Delete button, which matches `deleteExchange` refusing archived logs.
|
|
494
513
|
|
|
495
514
|
**Expand grows upward, toggle sits below.** The archived block lives above the live conversation, so opening it inserts content **above** the viewport; the toggle pins the distance-from-bottom across the click (the same anchor trick as the load-older prepend above) instead of letting the browser keep `scrollTop`, which would otherwise land the reader on the *oldest* archived message. Collapse is symmetric. The toggle bar itself renders **below** the expanded `MessageList`, not above it — since expansion grows upward, a bar above the content would end up a full scroll away once open, forcing a climb to collapse it. Its chevron points **up** when expanded (content it collapses is above it, not below). One scroll-to-top gesture (or the row's click) pulls exactly one segment; pulled segments live in `segmentCache`, a bounded FIFO capped at 20 (`SEGMENT_CACHE_LIMIT`, keyed `sessionId\0n`) — small enough to bound memory across a session of casual archive-browsing, and moot for any one segment since a committed segment is immutable and never re-fetched once cached.
|
|
496
515
|
|
|
497
|
-
**The Sessions tab's detail panel walks the same segments through a second store.** `session-chat-panel.tsx`'s viewer isn't wired to the Chat panel's singleton `archive-store` — repointing that store at an arbitrary selected session would clobber whatever the live chat is showing — so it keeps its own `session-archive-store.ts`, same one-segment-per-gesture walk, different anchor source: a non-live selection anchors from the `archiveCount`
|
|
516
|
+
**The Sessions tab's detail panel walks the same segments through a second store.** `session-chat-panel.tsx`'s viewer isn't wired to the Chat panel's singleton `archive-store` — repointing that store at an arbitrary selected session would clobber whatever the live chat is showing — so it keeps its own `session-archive-store.ts`, same one-segment-per-gesture walk, different anchor source: a non-live selection anchors from the `archiveCount` of the sidebar's `GET /sessions/logs/:id` (the committed segments on disk, counted once per selection); selecting the live session (the one the open chat is on) instead mirrors the chat archive store's anchor, since that one already arrived off the subscribe snapshot and the sidebar skips the GET for it. Rendering differs too: pulled segments prepend **expanded** above the active log rather than behind the Chat panel's collapsed toggle, with a boundary row (`Archived · N segment(s) · M messages`) marking where the archive ends and the active log begins; archived rows render read-only, matching `deleteExchange` refusing sessions with any archived segment. No segment cache in this store — each gesture issues its own GET, acceptable since browsing the Sessions tab is comparatively rare next to the Chat panel's own scroll-up. An archive landing while the panel is open follows the same anchor-mobility rule as above for a non-live selection (the file-size `file:changed` refetch re-anchors, restarting the walk); for the live selection it waits on the next subscribe/reattach, same as the Chat panel.
|
|
498
517
|
|
|
499
518
|
## Model message format dependencies
|
|
500
519
|
|