fix(tui): persist transcript to session file after every turn
Previously the tui lazily flushed the agent messages to the session file only at exit via WriteNewTranscript, plus opt-in via /session export or /session tree. That meant a mid-session crash, kill -9, or power loss dropped the entire conversation from disk even though the summary was visible in the scrollback. Now the turn-drain goroutine in startTurn() calls FlushSession() right after i.agent.Prompt returns, while the turn memory is still hot. FlushSession is the same idempotent helper used by /session export and /session tree: it appends only the rows past the current baseline and advances the baseline, so double writes cant happen even if the exit-time flush also fires. Ordering in the goroutine: lock -> clear busy/streamOn/cancel -> read the flush callback -> unlock -> flush -> relock for the queue-drain and auto-compact decisions. The short unlocked window is safe because no other goroutine reads those fields at that moment (busy is already false). No new config hook; reuses the existing FlushSession the cli wires in.
- commit
- 616eed3bd63b2b12fdca5d1858c8ffe98a565fea
- author
- patriceckhart · · 2026-04-20 10:11 UTC
- parent
- 1dafef8
- signature
- unsigned
- refs
- tag: v0.0.63
1 file · +12 -0
M internal/agent/modes/interactive.go +12 -0
| @@ -2068,6 +2068,18 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) { | |||
| 2068 | 2068 | if err != nil && ctx.Err() == nil { | |
| 2069 | 2069 | i.statusErr = err.Error() | |
| 2070 | 2070 | } | |
| 2071 | + | // Persist the assistant's reply (and every tool row before | |
| 2072 | + | // it) to the session file while the turn memory is hot. | |
| 2073 | + | // Without this, WriteNewTranscript only fires at zot exit, | |
| 2074 | + | // meaning a crash or ungraceful kill drops the whole | |
| 2075 | + | // conversation. FlushSession is idempotent (it advances the | |
| 2076 | + | // baseline so subsequent flushes only write new rows). | |
| 2077 | + | flush := i.cfg.FlushSession | |
| 2078 | + | i.mu.Unlock() | |
| 2079 | + | if flush != nil { | |
| 2080 | + | flush() | |
| 2081 | + | } | |
| 2082 | + | i.mu.Lock() | |
| 2071 | 2083 | // Pop the next queued message, if any, and relaunch. | |
| 2072 | 2084 | var next string | |
| 2073 | 2085 | var hasNext bool | |
| @@ -2068,6 +2068,18 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) { | |||||
| 2068 | if err != nil && ctx.Err() == nil { | 2068 | if err != nil && ctx.Err() == nil { | ||
| 2069 | i.statusErr = err.Error() | 2069 | i.statusErr = err.Error() | ||
| 2070 | } | 2070 | } | ||
| 2071 | + | // Persist the assistant's reply (and every tool row before | |||
| 2072 | + | // it) to the session file while the turn memory is hot. | |||
| 2073 | + | // Without this, WriteNewTranscript only fires at zot exit, | |||
| 2074 | + | // meaning a crash or ungraceful kill drops the whole | |||
| 2075 | + | // conversation. FlushSession is idempotent (it advances the | |||
| 2076 | + | // baseline so subsequent flushes only write new rows). | |||
| 2077 | + | flush := i.cfg.FlushSession | |||
| 2078 | + | i.mu.Unlock() | |||
| 2079 | + | if flush != nil { | |||
| 2080 | + | flush() | |||
| 2081 | + | } | |||
| 2082 | + | i.mu.Lock() | |||
| 2071 | // Pop the next queued message, if any, and relaunch. | 2083 | // Pop the next queued message, if any, and relaunch. | ||
| 2072 | var next string | 2084 | var next string | ||
| 2073 | var hasNext bool | 2085 | var hasNext bool | ||