Skip to content

feat: durable transcript projection — renderer renders state, not history-of-pushes - #422

Merged
dimavedenyapin merged 13 commits into
fix/coordinator-subagent-lifecyclefrom
feat/durable-transcript-projection
Jul 27, 2026
Merged

feat: durable transcript projection — renderer renders state, not history-of-pushes#422
dimavedenyapin merged 13 commits into
fix/coordinator-subagent-lifecyclefrom
feat/durable-transcript-projection

Conversation

@dimavedenyapin

Copy link
Copy Markdown
Contributor

Stacked on #421 (fix/coordinator-subagent-lifecycle). Merge #421 first, then retarget/merge this.

Problem

The transcript UI depends on the renderer catching every live agent:output IPC push, compensated by dedup sets, session re-key redirect maps, and pre-idle replay reconciliation. Any turn that runs while no view is bound — background wake-up turns, silently resumed sessions, app restarts — can render late or not at all. Observed live: a coordinator woken by subtask completion produced its ACK, but the transcript didn't show it until unrelated task_updated events flushed the view.

Approach

Make the main process the authoritative owner of the transcript (a durable projection), and make the renderer hydrate state from snapshots instead of depending on having observed every event. Live events remain the fast path for streaming; snapshots guarantee completeness.

1. Durable projection — database.ts

  • New transcript_parts table: per-task monotonic seq, upsert by (task_id, part_id) — streaming updates replace content in place, keeping position.
  • upsertTranscriptParts / getTranscriptParts(sinceSeq?) / hasTranscriptParts / getTranscriptMaxSeq / deleteTranscriptParts (wired into deleteTask).

2. Write-through at the emission chokepoint — agent-manager.ts

  • sendToRenderer persists every agent:output / agent:output-batch part before any client sees it (single chokepoint covers all 9 emission sites).
  • Ephemeral part types (step-start/step-finish/system-status) skipped.
  • History replays (session resume, mobile catch-up) marked replay: true — they only seed an empty transcript (backfill), since original parts were persisted when first emitted.
  • Resume re-binding fix folded in: resumeAdapterSession now persists task.session_id and emits task:updated before any follow-up prompt — a silent main-process resume (event-driven wake-up) re-binds the renderer up front instead of after the first output event.

3. Snapshot hydration — renderer

  • New agentSession:getTranscriptSnapshot(taskId, sinceSeq?) IPC (main → preload → types → ipc-client).
  • agent-store.hydrateTranscript(taskId): merges by part id — snapshot authoritative for content/order; in-memory extras the projection intentionally skips are preserved; snapshot ids marked seen so live events dedupe.
  • Triggers: task view bind (useAgentSession mount) and every idle transition — including idle events for sessions this window never observed (the exact wake-up-ACK case).

Testing

  • 8 new main tests: projection CRUD/seq/streaming-update/sinceSeq, write-through, ephemeral skip, replay-seeds-only-empty, snapshot accessor
  • 3 new renderer tests: hydration for never-seen tasks, authoritative merge + extras preservation, empty-snapshot no-op
  • Full suites green: 936 main / 562 renderer, typecheck clean

🤖 Generated with Claude Code

dimavedenyapin and others added 12 commits July 16, 2026 23:35
…tory-of-pushes

The transcript UI previously depended on the renderer catching every live
agent:output IPC event, compensated by dedup sets, session re-key redirect
maps, and pre-idle replay reconciliation. Output produced while no view was
bound — background wake-up turns, silently resumed sessions, app restarts —
could render late or not at all (e.g. a coordinator's wake-up ACK missing
from the transcript).

This makes the main process the authoritative owner of the transcript:

1. Durable projection (database.ts)
   - New transcript_parts table: per-task monotonic seq, upsert by
     (task_id, part_id) so streaming updates replace content in place.
   - upsertTranscriptParts / getTranscriptParts(sinceSeq) / hasTranscriptParts /
     getTranscriptMaxSeq / deleteTranscriptParts (wired into deleteTask).

2. Write-through at the emission chokepoint (agent-manager.ts)
   - sendToRenderer persists every agent:output / agent:output-batch part
     BEFORE any client sees it. Ephemeral part types (step-start/step-finish/
     system-status) are skipped. History replays (session resume, mobile
     catch-up) only seed an empty transcript — marked with replay: true.
   - Resume now persists the session binding and emits task:updated with the
     new session_id BEFORE any follow-up prompt, so a silent main-process
     resume (event-driven wake-up) re-binds the renderer up front.

3. Snapshot hydration (renderer)
   - New agentSession:getTranscriptSnapshot IPC (main -> preload -> types ->
     ipc-client). agent-store.hydrateTranscript merges snapshots by part id:
     snapshot is authoritative for content and order; in-memory extras the
     projection skips are preserved.
   - Hydration triggers: task view bind (useAgentSession mount) and every
     idle transition — including idle events for sessions this window never
     observed. Live events remain the fast path for streaming; hydration
     guarantees the final state is always complete.

Tests: 8 new main-process tests (projection CRUD, write-through, replay
seeding, snapshot accessor), 3 new renderer tests (hydration merge), plus
mock updates. Full suites green: 936 main, 562 renderer. Typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ntent collapse)

Providers that re-list the whole thread on every idle/turn-completed (Codex
app-server reconcileCompletedTurn) re-emitted the session's EARLIEST messages
after going idle. Root cause: renderer dedup consulted only the module-level
`seen` set, which is capped at 10k ids and evicts oldest-first — so on a long
session the earliest ids fall out of `seen` and the re-listed parts re-append.

Fix: add a non-evicting per-task rendered-id set that mirrors the messages
array exactly, and dedup against it (id-only) in both output handlers, the
streaming-update branches, and hydration. The set is rebuilt on hydrate and
reset on init(new)/remove/clearMessageDedup.

Dedup stays strictly id-based (the durable projection's stable part id), never
content-based, so two legitimately-identical messages — e.g. the user sending
the same text twice — are never collapsed (guards the "my message got replaced
by my previous identical message" regression). O(1) per part; the earlier
array-scan approach was O(n^2) and timed out on long sessions.

Adds 3 renderer tests: evicted-id no-re-append, identical-messages-not-collapsed,
streaming-update-after-eviction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sending a message after the renderer lost its sessionId falls back to
sendByTaskId → the backend resumes and replays the WHOLE thread. That replay,
merged into the accumulating in-memory list, reordered and duplicated the
transcript ("reloaded all messages, now broken").

Root cause was in hydrateTranscript: in-memory parts not in the snapshot were
appended at the END (losing chronological position) and a replayed part carrying
a different id than its live copy was kept as a duplicate.

Make the durable projection the single authoritative, ordered transcript:
- Render the snapshot verbatim in persisted seq order.
- Keep only genuinely-unpersisted in-memory parts: an extra is dropped if its id
  is in the snapshot OR its content already appears in a snapshot part (collapses
  the "same message under a different id" replay duplicate), and surviving extras
  are re-inserted in TIMESTAMP order rather than appended at the end.
- After a recovery send (session recreated/resumed), the renderer hydrates so the
  replayed thread is immediately reconciled to the authoritative projection.

Dedup between two DIFFERENT in-memory messages stays id-based (identical user
messages are never collapsed) — content is only compared against the snapshot.

Tests: hydration now asserts verbatim order, same-content-different-id dedup,
timestamp-ordered extras, and idempotent re-hydration. 566 renderer tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed chronology)

The transcript_parts projection stamped created_at at WRITE time, so a bulk
seed/replay (whole history written in one burst) gave every row a near-identical
timestamp — collapsing the transcript to a single instant and scrambling order
on reload (observed: all messages showing one clock time).

Persist the part's original receivedAt as created_at when known (fall back to
write-time otherwise), and preserve created_at across later reconcile upserts so
a reconcile pass never rewrites a part's original time.

Tests: bulk-seed keeps distinct chronology, write-time fallback, created_at
preserved across reconcile. 939 main tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n history

loadSessionHistory parsed the session JSONL but discarded each entry's
timestamp, so replayed/rehydrated messages reached the renderer with no
receivedAt — the UI then stamped them with new Date() (current time) and the
durable projection stored write-time. Result: old messages (e.g. an auth-error
from a day earlier) rendered as if they happened "just now" on every reload.

Thread entry.timestamp → part.receivedAt for every part in a loaded message.
Combined with the projection's created_at provenance, replayed history now
carries its true time end to end (adapter → renderer → durable read model).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ingle source

Event-sourced step: make the durable projection COMPLETE for sessions that
predate write-through, so the client can render from the projection alone
(no adapter replay-push needed).

getTranscriptSnapshot now, when the projection is empty, ingests the task's
persisted session history ONCE and then serves the snapshot. Ingest is:
- side-effect-free: reads the CLI's on-disk session (new adapter
  getPersistedMessages → Claude Code loadSessionHistory) with no CLI/SDK spawn
  and no live session required;
- idempotent: upsert-by-part-id + an in-flight guard + hasTranscriptParts
  re-check mean a live event or a repeat call never duplicates;
- faithful: carries each part's real receivedAt so backfilled history keeps
  true timestamps (not write-time);
- best-effort: no-ops when unavailable (missing session_id/agent_id or an
  adapter without getPersistedMessages).

This makes the projection authoritative and complete, killing the "blank/partial
transcript on reload" at the data layer. Client rendering stays snapshot-based.

Tests: ingest-once with real timestamps + ephemeral exclusion, no backfill when
already populated, idempotent second call, safe no-op without adapter support.
943 main + 566 renderer tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pear + overlap)

hydrateTranscript() on send replaced the whole messages array while the backend
replay was still streaming — causing the transcript to blank then repopulate
(disappear/reappear) and transiently overlap on relaunch+send. It fought the
live render path.

Load-time reconciliation (view-bind + idle hydration from the now-complete
projection) already covers ordering; the send path no longer triggers a
full-list replace. Removes the on-send hydrate in both branches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lizer overlap)

The transcript is a dynamically-measured virtual list (@tanstack/react-virtual,
absolutely-positioned rows via translateY + measureElement). The previous
hydration replaced/reordered the whole messages array (verbatim snapshot +
timestamp-sort), which invalidated the virtualizer's height cache and painted
rows on top of each other — the "text overlap" in the UI.

Hydration is now additive and never touches already-rendered rows:
- Empty view (post-reload): render the full ordered projection once.
- Live view: append only snapshot parts not already rendered, at the end;
  existing rows are left byte-for-byte stable so the virtualizer keeps its
  measurements. Missing is computed against actual rendered message ids, so a
  stale id-set can't cause a re-append.

Tradeoff: a rare replay "same content under a different id" duplicate can remain
(previously collapsed by a content check that also risked wrongly merging
identical user messages). That is eliminated properly by the single-source
step (remove the backend replay-push), not by mutating the rendered list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…onger missing on reload)

Two bugs made a reload show only the user's messages, in the wrong order:

1. Incompleteness: the backfill only ran when the projection was EMPTY. A
   projection that already held recent user echoes (write-through) but whose
   assistant replies predate write-through was treated as "populated" → backfill
   skipped → assistant messages never ingested → reload showed only user side.
   Fix: ingest the FULL persisted session history once per app run (idempotent,
   dedup by id AND by role+content so a live user echo and its persisted-session
   twin never double), regardless of whether the projection is partially filled.

2. Ordering: the projection was read in insertion (seq) order. Backfilled older
   history would then sort AFTER newer echoes. Fix: order snapshots by real event
   time (created_at) with seq as a stable tiebreaker, so ingest order is
   irrelevant to display order.

Together the projection becomes the complete, correctly-ordered single source
the renderer hydrates from. Tests updated: gap-fill with id+content dedup,
per-run idempotency. 943 main + renderer suites pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Logs which hydration path runs (EMPTY vs ADDITIVE), snapshot/existing/missing
counts, and when clearMessageDedup wipes the list — to pinpoint the reload race
where projection-stored assistant messages don't render. No behavior change.
To be reverted once the render bug is fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…; drop temp diagnostics

Root cause of "assistant messages missing after reload": on reload the durable
projection is hydrated into the view on mount, but resume() then called
clearMessageDedup() which wiped the message list — and the resume replay only
partially repopulated it, so projection-stored assistant messages vanished from
the render while live/most-recent ones remained.

Fix: resume() no longer clears the list. The replay batch dedups against the
hydrated messages by shared part id, so nothing is lost or duplicated by leaving
the projection-hydrated history in place.

Also removes the temporary [hydrate]/[clearMessageDedup] console diagnostics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Completes the resume-clear fix: the canvas-store browser- and terminal-
connection handlers also called clearMessageDedup before resuming, which wiped
the hydrated durable transcript. Removed both, matching the use-agent-session
resume fix. The resume replay dedups against the hydrated history by part id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ate (single source) (#429)

* feat(transcript): Phase 1 — rev cursor, delta query, transcript:changed push

Event-sourced foundation so clients can subscribe to the projection:
- database.ts: global monotonic `rev` column on transcript_parts (idempotent
  migration + backfill for existing DBs). upsertTranscriptParts bumps rev on
  insert AND content-update and returns {maxRev, changedPartIds}.
  getTranscriptDelta(taskId, sinceRev) -> {parts, maxRev}; getTranscriptMaxRev.
  Snapshot rows now carry rev.
- agent-manager.ts: after write-through, emit `transcript:changed` delta
  directly to clients (bypasses persist to avoid recursion) — the single
  low-latency + durable push path. Public getTranscriptDelta (ensures backfill).
- IPC (5 files): agentSession:getTranscriptDelta + onTranscriptChanged.

Renderer still uses the old path; cutover is Phase 2/3. 947 main tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(transcript): Phase 2/3 — renderer renders the projection as single source

Rewrite the desktop agent-store transcript layer to the event-sourced model:

- Per-task projection cache Map<partId, part>; `messages` is a DERIVED sorted
  list (createdAt, seq), never mutated by live events. One write path:
  applyParts(), fed by (a) full snapshot on bind (hydrateTranscript) and
  (b) idempotent `transcript:changed` deltas.
- Deleted the competing writers: onAgentOutput/onAgentOutputBatch accumulation,
  flushPendingBatches, seenIds/renderedIds dedup sets, hasRecentDuplicateError,
  the additive/replace hydration body, and message-resetting on init/clear/rekey.
  clearMessageDedup is now a no-op (kept for API compat).
- onAgentStatus handles session STATE only (status/pendingApproval/sessionId);
  at idle it runs a safety-net reconcileDelta to catch any missed delta.
- Because parts are keyed by stable id and sorted deterministically, reorder,
  duplication, collapse, and the virtualizer-overlap are structurally impossible.

Desktop no longer consumes agent:output at all; the backend replay-push remains
only for mobile (unchanged). Tests rewritten for the projection model
(delta apply, idempotency, streaming update in place, out-of-order convergence,
snapshot bind). 549 renderer + 947 main pass; typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(transcript): explicit send-after-restart no-loss scenario

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Migrate mobile transcript to the event-sourced projection model

Mobile previously rendered the transcript from an imperative replay push
(POST /sessions/:id/sync → replaySessionMessages → agent:output-batch),
a competing writer that reordered/duplicated messages on reconnect and
send-after-idle — the same class of bug just fixed on desktop.

Move mobile onto the identical single-source model as desktop:

- Client renders a per-task projection cache keyed by stable part id;
  the message list is DERIVED (sorted by createdAt, seq), so reorder,
  duplication, and collapse are structurally impossible.
- Bind loads a full durable snapshot (GET /tasks/:id/transcript) and
  applies idempotent `transcript:changed` deltas over WebSocket; idle
  reconciles via the rev-cursor delta endpoint.
- syncActiveSessions registers active sessions (status only) and binds
  each transcript from the projection — no replay push.

Remove the now-dead replay path end to end:
- Delete AgentManager.replaySessionMessages and repoint the mobile
  /sync route to a status-only ping.
- resumeAdapterSession no longer streams historical messages to clients;
  it only seeds the in-memory dedup state. The projection (snapshot +
  deltas) is the sole render source on both platforms.
- Drop the dead `replay` write-through guard; live output is idempotent
  at the DB layer (upsert keyed by part id) and historical seeding is
  handled solely by the one-time projection backfill.

Tests updated for the projection model on both platforms; full suite,
typecheck, lint, and build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(transcript): mark mobile projection migration done

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(transcript): don't build the rev index before the rev column exists

Startup crashed on a DB created before the `rev` column existed:

    SqliteError: no such column: rev
      at DatabaseManager.createTables

createTables ran the `(task_id, rev)` index creation, but on a legacy DB
the `CREATE TABLE IF NOT EXISTS transcript_parts` is a no-op (the table
already exists without `rev`), and the ALTER TABLE migration that adds the
column runs *after* createTables — so the index referenced a column that
didn't exist yet and aborted the whole schema init.

Move the rev index out of createTables and into ensureTranscriptRevColumn,
which runs after the column is guaranteed present (declared in CREATE TABLE
on a fresh DB, or added by ALTER on a legacy DB). The index creation now
runs unconditionally there (idempotent via IF NOT EXISTS).

Adds a regression test that drives createTables + ensureTranscriptRevColumn
against a legacy transcript_parts table (no rev column): asserts no throw,
the column is added, existing rows are backfilled with a monotonic rev, the
index exists, delta queries work, and the migration is idempotent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(transcript): backfill only seeds an EMPTY projection (no reader-triggered dupes)

Connecting a mobile client repeated older messages on BOTH mobile and
desktop. A mobile connect is meant to be a pure read, but the snapshot/delta
REST routes call getTranscriptSnapshot/getTranscriptDelta, which run the
one-time backfill. On a task already captured by live write-through, the
backfill re-ingested the persisted session JSONL — whose part ids differ
from the live-captured ids — and its content-based dedup missed tool /
taskProgress / partially-streamed parts, so it re-inserted already-present
messages under new ids. Every upsert broadcasts a `transcript:changed`
delta to ALL clients, so those duplicates leaked to the desktop view too.

Fix: backfill now seeds ONLY an empty projection. If the task already has
parts, the live write-through capture is authoritative and is never
re-ingested — so a reader (mobile or desktop) connecting to a populated
task performs zero writes and emits zero deltas. Backfill remains a
one-time seed for sessions that predate the store. The now-dead
dedup-against-existing logic is removed (the projection is empty when we
reach the ingest).

Tests: replace the "fills gaps when partially populated" test (which
asserted the dangerous re-ingest) with one proving a snapshot read on a
populated projection writes nothing and doesn't even read history, plus an
empty-projection seed test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(transcript): mobile transcript routes are pure DB reads

Make the mobile REST transcript routes read the durable projection straight
from the DB (db.getTranscriptParts / db.getTranscriptDelta) instead of going
through AgentManager.getTranscriptSnapshot/getTranscriptDelta, which can
trigger the one-time backfill/ingest.

Mobile is a pure reader: connecting from a phone must never mutate the
projection or broadcast `transcript:changed` deltas to other clients. The
desktop (session owner) keeps ownership of the one-time seed for sessions
that predate the store. This complements the empty-only backfill guard —
mobile now cannot trigger ingest at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(transcript): stop idle-replay from duplicating codex messages under mismatched ids

Root cause of "old messages repeat" (survived the empty-only backfill guard —
this is a different write path): replayMissedTranscriptPartsBeforeIdle runs on
every session's idle transition, re-reads the full thread via
adapter.getAllMessages, and re-emits any part not in seenPartIds. The codex
app-server adapter assigns a message's LIVE streaming delta a different part id
(agent-msg_<hash>) than its FINALIZED thread item (agent-item-N). seenPartIds
holds the live ids, so every finalized item looked "new" and was re-emitted —
persisting a duplicate of each assistant message under the agent-item-N id on
every idle. Because each upsert broadcasts a transcript:changed delta, the dupes
appeared on desktop and mobile alike, accumulating across all sessions.

Fix: replayMissedTranscriptPartsBeforeIdle now dedups candidates against the
durable projection BY CONTENT (normalized), not just by the (mismatched) part
id. A part whose text is already persisted is skipped (its id is remembered so
it isn't reconsidered), so the safety-net re-read only fills GENUINELY missing
content. Tool parts use stable call ids (toolu_/call_) and don't collide, so
they're untouched — avoiding the risk of collapsing legitimately repeated
identical tool calls.

Adds a regression test proving an already-persisted message arriving under a
new (agent-item-N) id is not re-emitted while genuinely new content still is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(transcript): "Starting…" indicator + input lock while an idle session resumes

Sending to an idle session triggers a slow backend resume (worktree + MCP +
adapter.resumeSession); the send call blocks until it finishes, and resume even
emits an interim `idle` status — so the UI sat on "Idle" with an open input and
no feedback for seconds.

Add a per-task `pendingSend` flag set the instant the user sends and cleared
only by the first non-idle status (working/waiting/error) — interim idle events
during resume don't clear it — plus a 120s safety timeout and an explicit clear
on send failure. While pending, the status pill shows a spinner + "Starting…"
and the composer is disabled (placeholder "Starting agent…") to prevent a
confusing double-send.

Wired on both platforms: desktop (agent-store + use-agent-session +
AgentTranscriptPanel, threaded via TaskWorkspace / TranscriptPanelContent /
OrchestratorPanel) and mobile (agent-store + ConversationPage). Store tests
cover begin/clear semantics (idle does not clear, working/endSend do).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@dimavedenyapin
dimavedenyapin merged commit cb7f899 into fix/coordinator-subagent-lifecycle Jul 27, 2026
dimavedenyapin added a commit that referenced this pull request Jul 27, 2026
…uple idle from termination (#421)

* fix: stop terminating subagents when the coordinator goes quiet; decouple idle from termination

Coordinator sessions that delegate work (subagent spawns, wait_for_subtasks)
legitimately produce no output for minutes at a time. The stuck-tool watchdog
(90s) and stuck-session watchdog (5min) treated that silence as a hang and
aborted the prompt — and the abort cascades into the child work (server-side
aborts kill child sessions; in-process subagents die with the parent query).
From the user's perspective: subagents were terminated whenever the main
agent went quiet/idle.

Core changes:

1. Delegation-aware watchdogs
   - Tools that delegate to subagents or block on subtask progress
     (task/agent/subagent, wait_for_subtasks, start_task) are exempt from
     the 90s stuck-tool abort.
   - The 5min stuck-session watchdog stands down while a delegation tool is
     running or the task has subtasks still being worked on — child progress
     counts as parent activity. Truly silent sessions still get aborted.

2. Idle is a state flag, not termination
   - Going idle never destroys a session or any child work. Termination is
     decoupled and handled by a separate low-frequency inactivity reaper:
     sweeps every 5 minutes, releases only sessions idle for 30+ minutes,
     and skips active turns, coordinators with running subtasks, pseudo-task
     sessions, and sessions without a persisted resume anchor.
   - Released sessions are resumed transparently by sendMessage via the
     persisted task.session_id, so an idle agent costs ~nothing and can
     always be woken later.

3. Event-driven coordinator wake-up (no polling of idle agents)
   - When a subtask reaches ready_for_review/completed (via transitionToIdle
     or the task API), the idle parent coordinator is resumed with a summary
     prompt once ALL subtasks are terminal. Parents no longer need to stay
     resident blocking on wait_for_subtasks — they can go idle and exactly
     one session is touched when there is something to act on.

Adds sessions' lastActivityAt tracking and 15 tests covering the watchdog
exemptions, the reaper guards, and the wake-up conditions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: durable transcript projection — renderer renders state, not history-of-pushes (#422)

* feat: durable transcript projection — renderer renders state, not history-of-pushes

The transcript UI previously depended on the renderer catching every live
agent:output IPC event, compensated by dedup sets, session re-key redirect
maps, and pre-idle replay reconciliation. Output produced while no view was
bound — background wake-up turns, silently resumed sessions, app restarts —
could render late or not at all (e.g. a coordinator's wake-up ACK missing
from the transcript).

This makes the main process the authoritative owner of the transcript:

1. Durable projection (database.ts)
   - New transcript_parts table: per-task monotonic seq, upsert by
     (task_id, part_id) so streaming updates replace content in place.
   - upsertTranscriptParts / getTranscriptParts(sinceSeq) / hasTranscriptParts /
     getTranscriptMaxSeq / deleteTranscriptParts (wired into deleteTask).

2. Write-through at the emission chokepoint (agent-manager.ts)
   - sendToRenderer persists every agent:output / agent:output-batch part
     BEFORE any client sees it. Ephemeral part types (step-start/step-finish/
     system-status) are skipped. History replays (session resume, mobile
     catch-up) only seed an empty transcript — marked with replay: true.
   - Resume now persists the session binding and emits task:updated with the
     new session_id BEFORE any follow-up prompt, so a silent main-process
     resume (event-driven wake-up) re-binds the renderer up front.

3. Snapshot hydration (renderer)
   - New agentSession:getTranscriptSnapshot IPC (main -> preload -> types ->
     ipc-client). agent-store.hydrateTranscript merges snapshots by part id:
     snapshot is authoritative for content and order; in-memory extras the
     projection skips are preserved.
   - Hydration triggers: task view bind (useAgentSession mount) and every
     idle transition — including idle events for sessions this window never
     observed. Live events remain the fast path for streaming; hydration
     guarantees the final state is always complete.

Tests: 8 new main-process tests (projection CRUD, write-through, replay
seeding, snapshot accessor), 3 new renderer tests (hydration merge), plus
mock updates. Full suites green: 936 main, 562 renderer. Typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: prevent earlier messages repeating on idle; id-only dedup (no content collapse)

Providers that re-list the whole thread on every idle/turn-completed (Codex
app-server reconcileCompletedTurn) re-emitted the session's EARLIEST messages
after going idle. Root cause: renderer dedup consulted only the module-level
`seen` set, which is capped at 10k ids and evicts oldest-first — so on a long
session the earliest ids fall out of `seen` and the re-listed parts re-append.

Fix: add a non-evicting per-task rendered-id set that mirrors the messages
array exactly, and dedup against it (id-only) in both output handlers, the
streaming-update branches, and hydration. The set is rebuilt on hydrate and
reset on init(new)/remove/clearMessageDedup.

Dedup stays strictly id-based (the durable projection's stable part id), never
content-based, so two legitimately-identical messages — e.g. the user sending
the same text twice — are never collapsed (guards the "my message got replaced
by my previous identical message" regression). O(1) per part; the earlier
array-scan approach was O(n^2) and timed out on long sessions.

Adds 3 renderer tests: evicted-id no-re-append, identical-messages-not-collapsed,
streaming-update-after-eviction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: transcript reorder/duplication after resume-replay on send

Sending a message after the renderer lost its sessionId falls back to
sendByTaskId → the backend resumes and replays the WHOLE thread. That replay,
merged into the accumulating in-memory list, reordered and duplicated the
transcript ("reloaded all messages, now broken").

Root cause was in hydrateTranscript: in-memory parts not in the snapshot were
appended at the END (losing chronological position) and a replayed part carrying
a different id than its live copy was kept as a duplicate.

Make the durable projection the single authoritative, ordered transcript:
- Render the snapshot verbatim in persisted seq order.
- Keep only genuinely-unpersisted in-memory parts: an extra is dropped if its id
  is in the snapshot OR its content already appears in a snapshot part (collapses
  the "same message under a different id" replay duplicate), and surviving extras
  are re-inserted in TIMESTAMP order rather than appended at the end.
- After a recovery send (session recreated/resumed), the renderer hydrates so the
  replayed thread is immediately reconciled to the authoritative projection.

Dedup between two DIFFERENT in-memory messages stays id-based (identical user
messages are never collapsed) — content is only compared against the snapshot.

Tests: hydration now asserts verbatim order, same-content-different-id dedup,
timestamp-ordered extras, and idempotent re-hydration. 566 renderer tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: preserve original message time in transcript projection (bulk seed chronology)

The transcript_parts projection stamped created_at at WRITE time, so a bulk
seed/replay (whole history written in one burst) gave every row a near-identical
timestamp — collapsing the transcript to a single instant and scrambling order
on reload (observed: all messages showing one clock time).

Persist the part's original receivedAt as created_at when known (fall back to
write-time otherwise), and preserve created_at across later reconcile upserts so
a reconcile pass never rewrites a part's original time.

Tests: bulk-seed keeps distinct chronology, write-time fallback, created_at
preserved across reconcile. 939 main tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: preserve real event timestamps when replaying Claude Code session history

loadSessionHistory parsed the session JSONL but discarded each entry's
timestamp, so replayed/rehydrated messages reached the renderer with no
receivedAt — the UI then stamped them with new Date() (current time) and the
durable projection stored write-time. Result: old messages (e.g. an auth-error
from a day earlier) rendered as if they happened "just now" on every reload.

Thread entry.timestamp → part.receivedAt for every part in a loaded message.
Combined with the projection's created_at provenance, replayed history now
carries its true time end to end (adapter → renderer → durable read model).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: one-time projection backfill so the durable transcript is the single source

Event-sourced step: make the durable projection COMPLETE for sessions that
predate write-through, so the client can render from the projection alone
(no adapter replay-push needed).

getTranscriptSnapshot now, when the projection is empty, ingests the task's
persisted session history ONCE and then serves the snapshot. Ingest is:
- side-effect-free: reads the CLI's on-disk session (new adapter
  getPersistedMessages → Claude Code loadSessionHistory) with no CLI/SDK spawn
  and no live session required;
- idempotent: upsert-by-part-id + an in-flight guard + hasTranscriptParts
  re-check mean a live event or a repeat call never duplicates;
- faithful: carries each part's real receivedAt so backfilled history keeps
  true timestamps (not write-time);
- best-effort: no-ops when unavailable (missing session_id/agent_id or an
  adapter without getPersistedMessages).

This makes the projection authoritative and complete, killing the "blank/partial
transcript on reload" at the data layer. Client rendering stays snapshot-based.

Tests: ingest-once with real timestamps + ephemeral exclusion, no backfill when
already populated, idempotent second call, safe no-op without adapter support.
943 main + 566 renderer tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: remove destructive re-hydrate on send (transcript disappear/reappear + overlap)

hydrateTranscript() on send replaced the whole messages array while the backend
replay was still streaming — causing the transcript to blank then repopulate
(disappear/reappear) and transiently overlap on relaunch+send. It fought the
live render path.

Load-time reconciliation (view-bind + idle hydration from the now-complete
projection) already covers ordering; the send path no longer triggers a
full-list replace. Removes the on-send hydrate in both branches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: make transcript hydration additive/non-destructive (stops virtualizer overlap)

The transcript is a dynamically-measured virtual list (@tanstack/react-virtual,
absolutely-positioned rows via translateY + measureElement). The previous
hydration replaced/reordered the whole messages array (verbatim snapshot +
timestamp-sort), which invalidated the virtualizer's height cache and painted
rows on top of each other — the "text overlap" in the UI.

Hydration is now additive and never touches already-rendered rows:
- Empty view (post-reload): render the full ordered projection once.
- Live view: append only snapshot parts not already rendered, at the end;
  existing rows are left byte-for-byte stable so the virtualizer keeps its
  measurements. Missing is computed against actual rendered message ids, so a
  stale id-set can't cause a re-append.

Tradeoff: a rare replay "same content under a different id" duplicate can remain
(previously collapsed by a content check that also risked wrongly merging
identical user messages). That is eliminated properly by the single-source
step (remove the backend replay-push), not by mutating the rendered list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: complete + correctly-ordered projection (assistant messages no longer missing on reload)

Two bugs made a reload show only the user's messages, in the wrong order:

1. Incompleteness: the backfill only ran when the projection was EMPTY. A
   projection that already held recent user echoes (write-through) but whose
   assistant replies predate write-through was treated as "populated" → backfill
   skipped → assistant messages never ingested → reload showed only user side.
   Fix: ingest the FULL persisted session history once per app run (idempotent,
   dedup by id AND by role+content so a live user echo and its persisted-session
   twin never double), regardless of whether the projection is partially filled.

2. Ordering: the projection was read in insertion (seq) order. Backfilled older
   history would then sort AFTER newer echoes. Fix: order snapshots by real event
   time (created_at) with seq as a stable tiebreaker, so ingest order is
   irrelevant to display order.

Together the projection becomes the complete, correctly-ordered single source
the renderer hydrates from. Tests updated: gap-fill with id+content dedup,
per-run idempotency. 943 main + renderer suites pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: add transcript hydration/clear diagnostics (temporary)

Logs which hydration path runs (EMPTY vs ADDITIVE), snapshot/existing/missing
counts, and when clearMessageDedup wipes the list — to pinpoint the reload race
where projection-stored assistant messages don't render. No behavior change.
To be reverted once the render bug is fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: don't clear the transcript on resume (keeps hydrated projection); drop temp diagnostics

Root cause of "assistant messages missing after reload": on reload the durable
projection is hydrated into the view on mount, but resume() then called
clearMessageDedup() which wiped the message list — and the resume replay only
partially repopulated it, so projection-stored assistant messages vanished from
the render while live/most-recent ones remained.

Fix: resume() no longer clears the list. The replay batch dedups against the
hydrated messages by shared part id, so nothing is lost or duplicated by leaving
the projection-hydrated history in place.

Also removes the temporary [hydrate]/[clearMessageDedup] console diagnostics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: don't clear transcript on browser/terminal-connection resume either

Completes the resume-clear fix: the canvas-store browser- and terminal-
connection handlers also called clearMessageDedup before resuming, which wiped
the hydrated durable transcript. Removed both, matching the use-agent-session
resume fix. The resume replay dedups against the hydrated history by part id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(transcript): event-sourced projection — client renders server state (single source) (#429)

* feat(transcript): Phase 1 — rev cursor, delta query, transcript:changed push

Event-sourced foundation so clients can subscribe to the projection:
- database.ts: global monotonic `rev` column on transcript_parts (idempotent
  migration + backfill for existing DBs). upsertTranscriptParts bumps rev on
  insert AND content-update and returns {maxRev, changedPartIds}.
  getTranscriptDelta(taskId, sinceRev) -> {parts, maxRev}; getTranscriptMaxRev.
  Snapshot rows now carry rev.
- agent-manager.ts: after write-through, emit `transcript:changed` delta
  directly to clients (bypasses persist to avoid recursion) — the single
  low-latency + durable push path. Public getTranscriptDelta (ensures backfill).
- IPC (5 files): agentSession:getTranscriptDelta + onTranscriptChanged.

Renderer still uses the old path; cutover is Phase 2/3. 947 main tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(transcript): Phase 2/3 — renderer renders the projection as single source

Rewrite the desktop agent-store transcript layer to the event-sourced model:

- Per-task projection cache Map<partId, part>; `messages` is a DERIVED sorted
  list (createdAt, seq), never mutated by live events. One write path:
  applyParts(), fed by (a) full snapshot on bind (hydrateTranscript) and
  (b) idempotent `transcript:changed` deltas.
- Deleted the competing writers: onAgentOutput/onAgentOutputBatch accumulation,
  flushPendingBatches, seenIds/renderedIds dedup sets, hasRecentDuplicateError,
  the additive/replace hydration body, and message-resetting on init/clear/rekey.
  clearMessageDedup is now a no-op (kept for API compat).
- onAgentStatus handles session STATE only (status/pendingApproval/sessionId);
  at idle it runs a safety-net reconcileDelta to catch any missed delta.
- Because parts are keyed by stable id and sorted deterministically, reorder,
  duplication, collapse, and the virtualizer-overlap are structurally impossible.

Desktop no longer consumes agent:output at all; the backend replay-push remains
only for mobile (unchanged). Tests rewritten for the projection model
(delta apply, idempotency, streaming update in place, out-of-order convergence,
snapshot bind). 549 renderer + 947 main pass; typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(transcript): explicit send-after-restart no-loss scenario

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Migrate mobile transcript to the event-sourced projection model

Mobile previously rendered the transcript from an imperative replay push
(POST /sessions/:id/sync → replaySessionMessages → agent:output-batch),
a competing writer that reordered/duplicated messages on reconnect and
send-after-idle — the same class of bug just fixed on desktop.

Move mobile onto the identical single-source model as desktop:

- Client renders a per-task projection cache keyed by stable part id;
  the message list is DERIVED (sorted by createdAt, seq), so reorder,
  duplication, and collapse are structurally impossible.
- Bind loads a full durable snapshot (GET /tasks/:id/transcript) and
  applies idempotent `transcript:changed` deltas over WebSocket; idle
  reconciles via the rev-cursor delta endpoint.
- syncActiveSessions registers active sessions (status only) and binds
  each transcript from the projection — no replay push.

Remove the now-dead replay path end to end:
- Delete AgentManager.replaySessionMessages and repoint the mobile
  /sync route to a status-only ping.
- resumeAdapterSession no longer streams historical messages to clients;
  it only seeds the in-memory dedup state. The projection (snapshot +
  deltas) is the sole render source on both platforms.
- Drop the dead `replay` write-through guard; live output is idempotent
  at the DB layer (upsert keyed by part id) and historical seeding is
  handled solely by the one-time projection backfill.

Tests updated for the projection model on both platforms; full suite,
typecheck, lint, and build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(transcript): mark mobile projection migration done

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(transcript): don't build the rev index before the rev column exists

Startup crashed on a DB created before the `rev` column existed:

    SqliteError: no such column: rev
      at DatabaseManager.createTables

createTables ran the `(task_id, rev)` index creation, but on a legacy DB
the `CREATE TABLE IF NOT EXISTS transcript_parts` is a no-op (the table
already exists without `rev`), and the ALTER TABLE migration that adds the
column runs *after* createTables — so the index referenced a column that
didn't exist yet and aborted the whole schema init.

Move the rev index out of createTables and into ensureTranscriptRevColumn,
which runs after the column is guaranteed present (declared in CREATE TABLE
on a fresh DB, or added by ALTER on a legacy DB). The index creation now
runs unconditionally there (idempotent via IF NOT EXISTS).

Adds a regression test that drives createTables + ensureTranscriptRevColumn
against a legacy transcript_parts table (no rev column): asserts no throw,
the column is added, existing rows are backfilled with a monotonic rev, the
index exists, delta queries work, and the migration is idempotent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(transcript): backfill only seeds an EMPTY projection (no reader-triggered dupes)

Connecting a mobile client repeated older messages on BOTH mobile and
desktop. A mobile connect is meant to be a pure read, but the snapshot/delta
REST routes call getTranscriptSnapshot/getTranscriptDelta, which run the
one-time backfill. On a task already captured by live write-through, the
backfill re-ingested the persisted session JSONL — whose part ids differ
from the live-captured ids — and its content-based dedup missed tool /
taskProgress / partially-streamed parts, so it re-inserted already-present
messages under new ids. Every upsert broadcasts a `transcript:changed`
delta to ALL clients, so those duplicates leaked to the desktop view too.

Fix: backfill now seeds ONLY an empty projection. If the task already has
parts, the live write-through capture is authoritative and is never
re-ingested — so a reader (mobile or desktop) connecting to a populated
task performs zero writes and emits zero deltas. Backfill remains a
one-time seed for sessions that predate the store. The now-dead
dedup-against-existing logic is removed (the projection is empty when we
reach the ingest).

Tests: replace the "fills gaps when partially populated" test (which
asserted the dangerous re-ingest) with one proving a snapshot read on a
populated projection writes nothing and doesn't even read history, plus an
empty-projection seed test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(transcript): mobile transcript routes are pure DB reads

Make the mobile REST transcript routes read the durable projection straight
from the DB (db.getTranscriptParts / db.getTranscriptDelta) instead of going
through AgentManager.getTranscriptSnapshot/getTranscriptDelta, which can
trigger the one-time backfill/ingest.

Mobile is a pure reader: connecting from a phone must never mutate the
projection or broadcast `transcript:changed` deltas to other clients. The
desktop (session owner) keeps ownership of the one-time seed for sessions
that predate the store. This complements the empty-only backfill guard —
mobile now cannot trigger ingest at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(transcript): stop idle-replay from duplicating codex messages under mismatched ids

Root cause of "old messages repeat" (survived the empty-only backfill guard —
this is a different write path): replayMissedTranscriptPartsBeforeIdle runs on
every session's idle transition, re-reads the full thread via
adapter.getAllMessages, and re-emits any part not in seenPartIds. The codex
app-server adapter assigns a message's LIVE streaming delta a different part id
(agent-msg_<hash>) than its FINALIZED thread item (agent-item-N). seenPartIds
holds the live ids, so every finalized item looked "new" and was re-emitted —
persisting a duplicate of each assistant message under the agent-item-N id on
every idle. Because each upsert broadcasts a transcript:changed delta, the dupes
appeared on desktop and mobile alike, accumulating across all sessions.

Fix: replayMissedTranscriptPartsBeforeIdle now dedups candidates against the
durable projection BY CONTENT (normalized), not just by the (mismatched) part
id. A part whose text is already persisted is skipped (its id is remembered so
it isn't reconsidered), so the safety-net re-read only fills GENUINELY missing
content. Tool parts use stable call ids (toolu_/call_) and don't collide, so
they're untouched — avoiding the risk of collapsing legitimately repeated
identical tool calls.

Adds a regression test proving an already-persisted message arriving under a
new (agent-item-N) id is not re-emitted while genuinely new content still is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(transcript): "Starting…" indicator + input lock while an idle session resumes

Sending to an idle session triggers a slow backend resume (worktree + MCP +
adapter.resumeSession); the send call blocks until it finishes, and resume even
emits an interim `idle` status — so the UI sat on "Idle" with an open input and
no feedback for seconds.

Add a per-task `pendingSend` flag set the instant the user sends and cleared
only by the first non-idle status (working/waiting/error) — interim idle events
during resume don't clear it — plus a 120s safety timeout and an explicit clear
on send failure. While pending, the status pill shows a spinner + "Starting…"
and the composer is disabled (placeholder "Starting agent…") to prevent a
confusing double-send.

Wired on both platforms: desktop (agent-store + use-agent-session +
AgentTranscriptPanel, threaded via TaskWorkspace / TranscriptPanelContent /
OrchestratorPanel) and mobile (agent-store + ConversationPage). Store tests
cover begin/clear semantics (idle does not clear, working/endSend do).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant