-
Notifications
You must be signed in to change notification settings - Fork 2
plat 178
| Coordination | Value |
|---|---|
| Assigned agent | Codex |
| Ticket state | 2026-09-23 stale-promotion jam fixed on main (evidence gate, 30m fail-after, dead-pane fast-fail); not deployed; live CLI verification pending |
| Last synchronized | 2026-09-24 |
- Priority: P1 — real conversation data loss, user-visible and confusing (the agent appears to "forget" recent work and asks the user to re-explain things it already did), and the recovery source needed to fix it is already sitting on disk unused.
-
Owner: conversation persistence (
cmd/server/chat_history_persistence.go,cmd/server/server.go's full-turn save block), terminal/session restore (cmd/server/chat_history_routes.go), Claude Code transcript reader (multi-llm-provider-go'sclaudecode_transcript_messages.go). - Related: PLAT-177) (same session, same resume boundary, different symptom — tool-access confusion vs. this ticket's conversation data loss; investigated together, filed separately since the root causes are unrelated).
The rapid-send fix made pending messages visible sooner, but delivery was still
split across /api/query, /live-input, Crew chat, Workflow Builder chat, and
server-owned schedule, trigger, and bot starts. Each path could independently
decide whether to inject into an occupied CLI, wait, or start another turn. That
made the correctness of a conversation depend on which producer submitted the
message.
A workspace-backed FIFO dispatcher now owns this boundary for all conversation
turns. When a session is occupied, it persists the complete turn request in
conversation-turn-dispatch.json, records the user message immediately as a
structured event with queued_for_turn, and starts it only after the current
turn releases the shared session lane. The browser displays an amber queued
receipt and queue position; confirmation or failure updates that same durable
message identity. Queue recovery runs before schedulers start after a server
restart. Persisted requests exclude decrypted secrets and API keys, which are
resolved again when execution begins.
The matrix covers regular chat, Crew, Workflow Builder, live input, schedules, triggers/webhooks, and bots. Retained CLI input is no longer a separate hidden queue when the active CLI is busy. Headless workflow execution is intentionally outside this dispatcher because it is an execution job rather than a conversation turn; its existing scheduler remains authoritative. Automatic completion notifications likewise retain their existing durable queue.
Before this dispatcher was deployed, RTS showed messages as permanent single
ticks while the server received no corresponding external /api/query or
/live-input request. The browser had already rendered each optimistic row,
but chatSubmissionLane put its network mutation behind an older unresolved
promise. Because the lane had no durable state or expiry, one hung preparation
or HTTP request caused unbounded head-of-line blocking for every later message
in that conversation.
The frontend lane has now been removed. Every user send starts its HTTP request
immediately, while the backend dispatcher provides the one authoritative FIFO,
durability, idempotency and restart recovery boundary. Exact rapid duplicate
clicks remain coalesced, but distinct user turns are never held only in browser
memory. The structured-source regression explicitly rejects reintroducing
chatSubmissionLane in the chat submission path.
The final frontend exception was removed at the same time: the queued-message
Steer action previously called /api/sessions/:id/live-input directly.
Enter, Send, queued decisions, report actions, and Steer now all submit through
/api/query; only the backend decides whether the message starts a turn, joins
the active provider, or waits in the durable FIFO. The /live-input server
route remains temporarily as a compatibility shim for older already-loaded
clients, with no caller in the current frontend.
RTS session c7d58080-0058-4f6f-a394-feea651f405b showed that several
messages sent while a retained Cursor/tmux turn was active did not appear in
Formatted Chat immediately. Server logs confirmed that delivery was ordered
and lossless, but each /api/query occupied the frontend's per-session promise
lane for 15–19 seconds while Cursor waited for a safe live-input boundary. The
frontend created its optimistic user row inside that lane, so the second and
later messages were invisible until every preceding request completed.
The frontend now stages each established session's optimistic user row before
entering the serialized delivery lane. The lane still owns network mutation
order and duplicate coalescing; a rejected submission removes its unstamped
optimistic row, while an accepted row continues through the existing durable
message-ID and delivery-receipt reconciliation. Focused regression tests cover
the visibility-before-enqueue invariant, promise-lane ordering, and duplicate
live-input coalescing. TypeScript production build passes; targeted ESLint has
no errors (two pre-existing ChatArea.tsx hook warnings remain). Deployment
and live rapid-multi-send verification are pending.
This is intentionally a visibility fix, not a fabricated fast delivery
receipt. The durable acknowledgement contract
requires Cursor's safe-composer transport acceptance before the single tick and
store.db evidence before the double tick. The observed 15–19 second interval
was before transport acceptance, not the asynchronous durable-ack watcher; the
contract explicitly rejects returning success before that attempted delivery.
On workflow "substack" (session b5e39872-4e4e-4645-8059-6d6e7a1231db), the
user resumed a chat and the agent presented an old assistant message ("Yes —
both bugs you reported are fixed... I also added a new 'Follow Health'
panel... still want to check the step-connect-creators wording?") as if it
were the current end of the conversation. It was not — the user had
continued the conversation well past that point. The user: "this is not the
final assistant response.. this is like the 2nd msg... what don't we resume
properly."
1. conversation_history is a one-shot snapshot, written only when a full
turn completes — not continuously as a live tmux CLI session runs.
The save happens once, at the end of the streamed /api/query handler,
after a turn completes:
-
[CONVERSATION DEBUG] Native continuation merge: ...—cmd/server/server.go:6233 -
[CONVERSATION DEBUG] Final save: ...—server.go:6236-6238 -
[BUILDER LOG] Saved conversation log (752 messages) to Workflow/substack/builder/conversation/2026-08-22/session-b5e39872-...-conversation.json—server.go:6358, inside theisWorkflowPhaseblock (server.go:6326-6363).
Live: this fired at 13:51:26 on 2026-08-22, saving exactly 752 messages
ending with the "Follow Health panel" reply. The file's mtime on disk is
13:51:26 and it was never written again.
A structurally identical one-shot writer exists for background "synthetic"
turns (cmd/server/background_agents.go:2394-2435). Neither path is
periodic; the only ticker in this area
(conversation_turn_lifecycle.go:317, 250ms) is a scheduled-message
completion poller, unrelated to persistence.
2. Once a session is "warm" (a live tmux pane already exists — exactly the
state right after any resume), further messages go through /api/live-input
instead of a full turn, and that path only ever persists the user's own
outgoing message, never the assistant's reply.
/api/live-input (server.go:8342-8477) delivers directly into the
retained tmux session (retainedSession.Send,
deliverRetainedMainTerminalInput, mcpagent.DeliverAgentInput) whenever
sessionHasLiveMainCodingTmux is true (server.go:8390) — bypassing the
full-turn handler entirely, so it never reaches the save block in finding
#1. Its only persistence hook is
appendLiveInputToPersistedChatHistory (called at server.go:8353,8370,
defined chat_history_persistence.go:2934-3009), which reads the existing
JSON, and appends only:
history = append(history, llmtypes.MessageContent{
Role: llmtypes.ChatMessageTypeHuman,
Parts: []llmtypes.ContentPart{llmtypes.TextContent{Text: message}},
})(chat_history_persistence.go:2976-2979) — hardcoded
ChatMessageTypeHuman. The CLI's assistant replies to these live-input
turns are never written back. Live: the user's session went straight into
this warm state after the 13:51:26 save (confirmed by
sessionHasLiveMainCodingTmux-gated CDP browser activity at 13:55:04 and
raw terminal polling through at least 14:31), so nothing after 13:51:26
ever reached conversation_history — even though real, substantial
conversation happened.
3. When the tmux pane later dies and the session needs to restore, the fallback tier trusts that same stale snapshot with no attempt to recover anything richer.
chat_history_routes.go:155-176 restore logic: tier attach_existing
(chat_history_routes.go:250-271) tries to reattach to the live tmux pane
via captureTerminalPane; live, this failed at 16:18:20 with
reason=tmux_session_not_running (the pane was gone by then). It falls
through to tier persisted_snapshot
(chat_history_routes.go:273-301/selectPersistedTerminalSnapshot at
chat_history_routes.go:303-314), which reads only the terminal_snapshots
already embedded in that same stale conversation JSON — i.e. the
13:51:26 state. No tier consults tmux scrollback, a separate transcript
log, or the CLI's own on-disk session file; the comment at
chat_history_routes.go:141-153 explains the two launch-based recovery
tiers are deliberately skipped here (a real, separate constraint — avoiding
a tool-registration race by deferring launch to the next /api/query) —
that constraint is legitimate but unrelated to this gap, and no other
tier was added to compensate for it.
4. The full, missing conversation genuinely exists on disk — the restore path just never reads it.
Claude Code CLI writes its own transcript independent of agent_go's JSON,
at ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl
(multi-llm-provider-go/claudecode_transcript_path.go:10-20). Confirmed
live at
~/.claude/projects/-Users-mipl-ai-work-mcp-agent-builder-go-workspace-docs-Workflow-substack/71ed3dcb-fe3c-412d-94cb-e62226fc4648.jsonl
(external_session_id from the session's own runtime.agent_session_handle)
— 155 entries timestamped after 13:51:26, including 36 real user
messages, 63 real assistant messages, 37 attachments, and 3 system entries,
running until a final <local-command-stdout>Bye!</local-command-stdout>
entry at 15:19:59 local time. None of this reached
conversation_history; all of it is still sitting in this file, unread by
the restore path.
readClaudeTranscriptMessages
(multi-llm-provider-go/claudecode_transcript_messages.go:43-134) already
knows how to reconstruct full []llmtypes.MessageContent (text, tool_use,
tool_result) from exactly this file format, but it is only ever called from
claudecode_interactive_adapter.go:664, scoped to a single turn's
turnStart..now window — never from the resume/restore path.
Any session that (a) resumes into a warm/retained tmux state — which is the
normal case, not an edge case, since a resumed session almost always still
has its tmux pane alive initially — and (b) exchanges any further messages
purely through /api/live-input before its tmux pane eventually dies for
any reason (intentional /bye, crash, terminal reap, host restart) will
have that entire stretch of conversation permanently invisible to
conversation_history, costs, and every UI/API surface that reads it —
even though Claude Code's own transcript still has it. This is not specific
to the substack workflow or to this one incident.
Four independent gaps were identified. All four are now implemented:
-
Persist every provider-confirmed live-delivery user message. Both
/api/queryretained delivery and/api/live-inputnow call the samepersistLiveInputUserMessagehelper after the coding CLI confirms receipt. This closes the 2026-09-16 Confida recurrence where session61247bd2-347b-4776-982f-e158e84f9ad6delivered the user's message to Pi and completed in the terminal, but the durable JSON stopped before the last two turns. The helper is reached by durablemcpagent, cold retained-tmux, and running-Agent delivery paths and keeps the authenticated internal user ID when writing history. -
Make the workflow-builder restore path read the native transcript (finding #3/#4) — implemented.
restoreLatestBuilderConversation(workflow_builder_session_routes.go:215-, the actual function that serves the stale snapshot to the chat UI when no live in-memory session matches — confirmed to be the real serving path, not the terminal-pane tiers inchat_history_routes.go, which restore the separate raw terminal widget) now callsrefreshLatestBuilderConversationFromNativeTranscript(claude_native_transcript_sync.go, new file) on the winning candidate before converting it to display events. Forprovider=claude-codesessions with a resolvable native session ID and working directory (read from the snapshot's ownruntime.agent_session_handle.providerblock), it locates Claude Code's own JSONL transcript (~/.claude/projects/<slug>/<session-id>.jsonl, using the same working-directory-to-slug escaping scheme asmulti-llm-provider-go/claudecode_transcript_path.go, duplicated rather than imported since that resolver is unexported), reads the full native transcript, and sequence-merges it with persisted history. It does not use the snapshot'supdated_atas a transcript cursor: persisting a later live-input human message advances that field before the preceding assistant reply is saved, so a timestamp cutoff can permanently skip the reply and duplicate the later user message. The merge preserves persisted-only prefix messages, uses shared ordered messages as anchors, inserts native-only replies between them, and deduplicates messages present in both sources. It keeps only real chat text (plain-string user content,text-typed assistant blocks —tool_use/tool_result/thinkingare filtered out as execution detail, not something either party said), and best-effort persists the merge back to the same file so later reads see it too. A failed persist (e.g. the workspace API being unreachable) is logged but never blocks the restore itself — the in-memory catch-up is still served.The Pi branch now resolves its native transcript with
picli.ReadNativeTranscriptFromWorkingDir(workingDir, nativeSessionID). The earlier default-store lookup could not see transcripts inside the session's isolated runtime directory, so completion synchronization retried three times and found no completed reply even though the JSONL contained the missing user message and full assistant answer. -
Preserve Pi thinking blocks as retained-turn progress. A second live check on the same Confida session used the message “can you group them by companies”. The terminal showed many narrated updates, including “Identifying Session Clues” and “Mapping Company IDs”, while the chat showed only “41 tool calls” until the final answer. Direct inspection of Pi's JSONL proved those updates were
thinkingblocks paired with tool calls and emptytextblocks. Normal Pi streaming already mapsthinking_deltato visible assistant progress, butReadRetainedTurnProgressMessagesrebuilt progress only fromtext, silently dropping the retained equivalent. The transcript summary now keeps a separateProgressMessagesprojection containing both thinking and text blocks in order. The final-answerMessagesprojection is unchanged, so thinking can never satisfy retained-turn completion or appear as the final answer. -
Always reconcile interactive workflow chats with durable history. A later reproduction in the same Confida session exposed a frontend-only restore race after the backend fixes above had correctly persisted the answer. The terminal contained the completed judge scorecard (
PASS,0.95 / 1.0, seven Langfuse scores andjudge_scorecard.md) and the durableconversation_historyalso contained that answer plus the nextdont add into langfuseexchange, while Chat stopped at the older “workflow execution is currently running” response. The persistedui_eventsarray was capped at 200 and ended before those durable turns. Workflow restoration treated any non-empty volatile event array as fully hydrated, so it never requested the authoritative conversation history.Interactive workflow tabs now reconcile with durable history even when a volatile event tail is present;
hydrateTabEventsthen deduplicates and merges that live tail so tool/progress events remain visible. Entering Formatted view also performs the same reconciliation, covering refreshes that initially reopen in Terminal view. Read-only schedule tabs retain the cheaper empty-only hydration rule.
The user-visible symptom — “the terminal has the answer but Chat lost it” — can be produced independently at four ownership boundaries:
- the retained coding-agent delivery path can accept a user message without appending it to the server's durable chat;
- the provider's native transcript can contain the completed answer while server recovery looks in the wrong working directory;
- the provider can represent visible progress as
thinkingrather thantext, causing retained progress reconstruction to omit it; and - the server's durable chat can already be correct while the frontend treats
a non-empty, bounded
ui_eventstail as a complete transcript and never reconciles the durable messages.
These are separate data paths that converge on the same screen, so closing one
does not prove the other three are closed. The invariant established by this
ticket is: the provider transcript is recovery evidence, durable
conversation_history is authoritative chat state, and ui_events is only a
bounded execution/progress projection. A non-empty ui_events array must never
suppress durable-history hydration.
- Deployed application revision
912b9347e473d078f3c82b72c1bf10a9fd1ebedcas Confida releaseconfida-912b9347-20260916083842on 2026-09-16. The release pipeline reported the agent API healthy, the public endpoint returned HTTP 200, and theconfida-agent,confida-gateway, andconfida-workspaceuser services were active after activation. - Live browser verification after a hard reload restored the durable Chat history rather than stopping at the stale capped UI-event tail. The UI displayed the previously missing local-only Langfuse configuration reply and the complete trace/business-context response. This confirms the deployed Formatted-view hydration is reading and merging authoritative history.
- The same reload also confirmed the bundled toolbar changes: Knowledgebase is top-level, Costs is under Ops, and Playbooks is under Setup.
- Provider-matrix verification covers every registered persistent live-input CLI: Claude Code, Codex CLI, Cursor CLI, Pi CLI, and Muse. Shared user-message persistence and frontend durable-history reconciliation are provider-neutral; each provider has retained-progress/final-answer readers and a native transcript recovery branch. Focused adapter, retained-session, and server recovery suites passed across all three repositories on 2026-09-16. A contract-derived regression now fails if a future persistent live-input CLI is registered without declaring transcript reading or without being included in native chat-history recovery.
Conversation e4f5abb1-57f3-4ba8-823e-87b3fe2e2a93 is a direct reproduction
of gap #4 after the fixed release had reached the server. The operator
confirmed the page had been refreshed, ruling out a pre-deployment frontend
bundle still running in memory:
- At 13:40:07 IST, the Formatted view stopped at intermediate progress (“Defining Post-Execution Report”, “Defining Next Steps”, and “Testing the Implementation”). At 13:40:21 IST, the Terminal view already displayed the completed response.
- The production conversation JSON had been updated at 13:38:31 IST and held
257 durable
conversation_historymessages, including the complete final “I have created the dedicated scripted step...” answer. Its 43ui_eventsended earlier. The answer therefore was not lost by the server; the formatted projection was stale because it trusted the non-empty UI-event tail. - Revision
912b9347e473d078f3c82b72c1bf10a9fd1ebedc, which implements the unconditional interactive-chat reconciliation, was packaged asconfida-912b9347-20260916083842. Confida's release names and filesystem timestamps use the host's UTC+02 timezone: 08:38 host time is 12:08 IST, about 91 minutes before the screenshots — not 14:08 IST as initially recorded. The current release,confida-7def0915-20260916090324, was active by 12:36 IST and includes that revision. - The follow-up reconciliation has an ordering defect.
hydrateTabEventsrestores durable conversation events first, filters duplicates from the in-memory event response, and then callsaddTabEventswith the remaining volatile tail.addTabEventsis append-only and Formatted view preserves array order. Therefore older, non-carrier progress events can be appended after the newer durable final answer. The newest answer is no longer the bottom item even though hydration fetched it successfully, producing the same apparent “final message lost” symptom. - This incident has the exact dangerous shape: the 43 persisted UI events end on the earlier 13:00 IST turn, the 257-message durable history ends with the 13:38 final answer, and the screenshot bottom shows intermediate progress rather than that final answer. The existing regression test checks that durable history is requested, but does not check final ordering after a non-carrier live progress tail is merged.
Classification: confirmed post-deployment frontend regression; no backend message loss. The first PLAT-178 frontend fix corrected the decision to fetch durable history, but did not enforce chronological/turn-relative ordering when combining that history with the live event tail. The follow-up must add an ordering regression test with a durable final answer plus older live progress, then merge the progress before its matching final carrier rather than blindly appending it.
Implemented and deployed on 2026-09-16:
-
hydrateTabEventsFromConversationis now the single ordering boundary for persisted UI events, raw events already received through SSE, and the current EventStore window. All three sources enterconversationToRestoredEventstogether, so its turn anchors and chronological projection apply once. - Removed both post-hydration append paths: normal page hydration and the
existing-tab restore path no longer call
addTabEventsafter constructing durable history. Legacy sessions without durable history keep their existing live-only fallback. - Repeated hydration filters its own synthesized events before rebuilding the trace, preventing generated timestamps from feeding back into the next projection.
- Durable history still paints before a slow live-status request completes; when the live response arrives, the same projection function reconciles it.
- The shared carrier extractor now handles both nested
{data:{content}}and legacy flat{content}event envelopes after restore metadata is attached. The broader product fallback suite exposed this pre-existing deduplication hole as a duplicate user row during the refactor. - Added regressions for the production ordering shape (older live progress plus a newer durable final answer) and flat-envelope carrier deduplication.
Verification: 53 focused restore/conversation tests pass, TypeScript project
compilation passes, focused ESLint passes, and git diff --check is clean.
Commit 84a102b2c4de39c1472d3ad7eda686c281257df9 was pushed to main and
deployed as Confida release confida-84a102b2-20260916134610. The deployment
pipeline passed release-asset and bundle-budget checks; confida-agent,
confida-gateway, and confida-workspace were active; internal agent health
and the public endpoint returned 200; workspace health was healthy with the
Landlock sandbox available. A hard-refreshed fresh retained turn remains the
final user-visible verification.
A Sheets Analysis chat showed the terminal and Formatted Chat disagreeing about
the same retained Claude session. In project chat session
work:project:be2a4cf4-0f3b-49f7-a3fb-084f5f8028c4 (Claude native session
ce286440-2463-442d-84f6-fbce00e88e2a, terminal
mcp-agent-20260921-001313), the terminal chronology correctly showed the user
asking "si sheet111 tab used for anything?" between two assistant turns. The
durable conversation omitted that user prompt: its final eight
conversation_history entries were all assistant messages, while the capped
UI-event tail ended before the prompt. Formatted Chat therefore grouped stale
assistant updates around the newest reply and made old content appear current.
The native Claude JSONL retained the missing boundary, but represented it as a
human queued_command attachment rather than a normal user entry: a
queue-operation enqueue was followed by an attachment with
type: "queued_command", humanTurn: true, and origin.kind: "human", then
the final assistant response. The backend transcript reader accepted only
normal user/assistant entries, so it silently discarded the queued user
turn and merged consecutive assistant updates across the lost boundary.
Two complementary fixes are now on main:
-
44c727fc3makes restored event identities content-bound and only applies theprependedIndexshift when the old tail was actually preserved. This prevents React/Virtuoso row reuse from displaying stale content when a bounded history page moves during hydration. -
eb92d4570recovers only genuinely humanqueued_commandattachments, unwraps their<pasted_content>payload, ignores non-human attachments, and inserts the recovered user message into sequence merging so the turn boundary cannot disappear.
Regression coverage includes the parser and sequence merge for the missing
human boundary, restored-event identity changes, and bounded-history scroll
reconciliation. Focused Go tests pass; all 31 frontend
sessionRestore/useTranscriptScroll tests pass; lint, Go build, workspace
build, and Electron build pass. Both commits are pushed to main; deployment
and a fresh-client retained-turn check remain pending.
2026-09-24 — deploys and config edits silently replaced the native CLI session ("the agent forgot yesterday")
Symptom (RTS, SDE crew gptlive1, claude-code): "what did we work on
yesterday" — the crew did not know. Its native Claude session changed from
3ccd4ab5… to c7f928e6… at 2026-09-23 18:32 UTC, the first turn after the
18:06 deploy. The new session only received a one-shot "read the archive"
notice; with 1,509 archived messages it skimmed chat_history/chat-index.json
previews instead.
Root cause — two fingerprints treated config drift as a new conversation:
| Where | Fingerprint | What changed it |
|---|---|---|
| Crew / product chats |
agentProfileSessionKey (profile Definition + selected MCP servers + identity); seedCodingAgentRuntimeFromRestoredConversation skipped native resume on mismatch |
any deploy touching the Work system prompt or tool allowlist; adding an MCP server |
| Workflow Builder/Run chats |
chatPolicySessionKey = role (mode, origin, capabilities) plus chat definition + MCP config + user config; any difference → [CHAT_POLICY] Policy refresh … starting a fresh native coding-agent session
|
any deploy touching the workflow chat prompt; any MCP/user-config edit |
Fixes (on main, not yet deployed at time of writing):
-
eb18a0e39— Crew/product chats resume the same native session when the profile definition changes; the stale retained process is still refused live input, which forces a relaunch with the current definition. -
e2e745939— workflow chats: newChatPolicyRoleKey; only a real role change (Builder↔Run, origin, capabilities) replaces the session. Definition/config drift keeps it. Legacy runtimes without the role key resume (mode still compared). -
5dbe11a83— when a fresh session is unavoidable, the continuity notice carries the recent user/assistant dialogue (sharedrecentDialogueLines) plus the archive path with "search it"; the Work prompt says where full history lives. -
Per-CLI verification that a resumed session actually receives the NEW instructions (rule changed ALPHA→BRAVO between two turns of one session):
CLI How instructions arrive Follows new rule after resume? claude-code CLAUDE.md rewritten per launch; recorded as a fresh instructionsattachment on resume (seen in the SDE transcript at 18:31 and 03:20)yes codex-cli AGENTS.md yes ( codex exec resume, local 0.156.1)pi-cli --append-system-promptevery launchyes (local 0.87.1) cursor-cli .cursor/rules(tmux) / first message only (structured)no — fixed in multi-llm 37b0666: resend a changed prompt inline once per native chat (sha256 marker); verified on RTS, later turns keep following itmuse-cli AGENTS.md no, and an inline override is ignored too → e29ac0384: for muse-cli only, instruction drift starts a fresh session with the recent dialogue (codingProviderReloadsInstructionsOnResume)Tools were never affected: every adapter regenerates its MCP config per launch and crew tools are fetched live via
get_api_spec.
Not verified yet: a live post-deploy check that crews and workflow chats
keep their native session across the next deploy (look for [CHAT_HISTORY] … resuming the same native coding-agent session instead of Native coding-agent continuation unavailable). The SDE crew's memory before
2026-09-23 18:32 cannot be restored natively; it is intact in
builder/conversation/.
Local Work chat session work:project:75db2aa1-a719-4365-a871-824d74b6930a
(muse-cli over a retained tmux pane, multi-agent mode): two submits —
"yes" at 21:01:08 and a ULIP-investment review at 21:01:27 IST — were
durably queued with positions 0/1 and stayed queued in the UI
indefinitely. No replies ever arrived. The queue doc
(workspace-docs/_users/default/chat_history/conversation-turn-dispatch.json)
still holds both turns with no StartedAt: never claimed, never
executed. The UI was telling the truth — the backend accepted the turns
and then never ran them.
- 20:00–20:42 — normal retained turns over tmux, each settling via
Settled retained main-agent turn from structured unified_completion event(20:18:50, 20:35:01, 20:42:07). Note every settle in this session carries a non-emptynext_execution: the promotion branch fires on every completion, not just rapid-fire ones. - 20:40:36 / 20:40:39 — rapid-fire submits ("setup purpose yourself",
"you know the different dashboards") while a response is active; their
execution IDs stack onto
retainedMainTurnPendingExecutionIDs. - 20:42:07 — settle logs
state=completed next_execution=query_1790173821969293000. The1790173821…timestamp embedded in that ID is 20:00:23 — the same ID appears in the 20:00:23[CODING_AGENT_DELIVERY]line. So the "next" execution is a turn that completed ~42 minutes earlier. It survived two intervening settles (20:18, 20:35 promoted other IDs) because nothing ever prunes the pending list — entries only leave via promotion or the failed-state branch. - 20:42–21:01 — gap census for the session shows only the settle, one chat-history persist, and timeline echoes. No new turn starts, no API submits, no delivery. The lane goes busy with no live turn behind it.
- 21:01:08 / 21:01:27 — both POSTs return 200 in ~150ms with
status=accepted, delivery_status=queued_for_turn(+ position). The frontend stamps the clock tick purely from that response (ChatArea→withLiveInputReceipt; there is no optimistic client-side queued state — verified by code search andgit log -S). - 21:12:07 —
[RETAINED_TURN] WARNING: turn still observed with no pane-idle and no durable final response … elapsed=30m1s quiet=7s. Elapsed counts from the 20:42 re-arm, confirming the watcher has been waiting on the phantom ever since.
- The 20:42 settle takes the promotion branch
(
observeRetainedMainTurnEvent,server.go~8686–8704): pending is non-empty and state is not failed, so it promotespending[0], re-armsretainedMainTurns[sessionID] = time.Now(), starts a freshobserveRetainedMainTurnStreamwatcher, and returns withpromotedExecutionID != "". - Because promotion happened, the settle skips
kickConversationTurnQueue(server.go:8760-8762) and re-marks the session busy/running. Correct for a live next turn; fatal for a phantom. - The watcher (
observeRetainedMainTurnStream,server.go:8362) can exit only via ctx-cancel, stream close/capture error, durable-sidecar settle, or pane-ready settle. None can fire here: the retained tmux session is gone (tmux lsshows only an unrelated Sept-19 session; only an orphanedtmux -CC attachprocess from 20:42 remains), the muse CLI session (01a0ce94-…) has been idle since ~19:37 (last writes areresource_pressure.observednoise), and the durable sidecar holds no new final response — the completion for this execution ID already happened at 20:00. The 30-min stuck warning (retainedMainTurnStuckWarnAfter) is log-only; the loop thencontinues forever. -
conversationTurnOccupied(conversation_turn_queue.go:184) stays true via the re-armedretainedMainTurns[sessionID]entry (one of six checks; the others are lane token, dispatch-pending, active cancel, stored turn flag, retainedActiveTurnID). Every kick — at enqueue, at every later submit, from every completion hook — returns early at the occupancy guard. The queued turns sit unclaimed; no failure is ever recorded, so the UI showsqueued, neverfailed.
Net: a single stale-string promotion converts the lane into a permanent busy signal with no timeout and no failure path. Every later submit in that session queues behind nothing, forever.
Environment: local dev server on the reporter's laptop
(run_server_with_logging.sh --with-workspace; agent :18743,
workspace :18744, docs-dir workspace-docs), Work product chat,
user default, session work:project:75db2aa1-a719-4365-a871-824d74b6930a,
provider muse-cli, transport tmux, retained terminal
…:main:work:project:75db2aa1-…. All times IST (UTC+5:30) on 2026-09-23.
Stale ID origin — 20:00:23 delivery line whose JSON contains
"query_1790173821969293000" (ID suffix 1790173821… = 20:00:23 UTC+5:30):
2026/09/23 20:00:23 [username=user workflow=new-project-75db2aa1 mode=multi-agent …] [CODING_AGENT_DELIVERY] {"cli_accepted_at":"2026-09-23T14:30:23.414383Z",… "query_1790173821969293000"…}
Phantom promotion — 20:42:07 settle promotes that same 42-minute-old ID:
2026/09/23 20:42:07 [RETAINED_TURN] Settled retained main-agent turn from structured unified_completion event session=work:project:75db2aa1-a719-4365-a871-824d74b6930a terminal=work:project:75db2aa1-a719-4365-a871-824d74b6930a:main:work:project:75db2aa1-a719-4365-a871-824d74b6930a state=completed next_execution=query_1790173821969293000 username=user workflow=new-project-75db2aa1
Queued submits — both POSTs return the small accepted JSON in ~150–217ms
(full-body submit path would take seconds and emit delivery lines):
2026/09/23 21:01:08 […] [API] <-- POST /api/agent-profiles/work/query request_id="-" status=200 bytes=299 duration=217ms in_flight=0
2026/09/23 21:01:27 […] [API] <-- POST /api/agent-profiles/work/query request_id="-" status=200 bytes=299 duration=157ms in_flight=0
Watcher warning — elapsed counts from the 20:42 re-arm (30m1s):
2026/09/23 21:12:07 [RETAINED_TURN] WARNING: turn still observed with no pane-idle and no durable final response session=work:project:75db2aa1-a719-4365-a871-824d74b6930a terminal=work:project:75db2aa1-a719-4365-a871-824d74b6930a:main:work:project:75db2aa1-a719-4365-a871-824d74b6930a provider=muse-cli elapsed=30m1s quiet=7s username=user workflow=new-project-75db2aa1
Queue doc (both turns intact, neither claimed — StartedAt absent):
{
"version": 1,
"turns": [
{"id": "963aaea7-d26f-4c8b-b637-50479b5e5e7e", "user_id": "default",
"session_id": "work:project:75db2aa1-a719-4365-a871-824d74b6930a",
"request": {"query": "yes", "provider": "muse-cli", "agent_mode": "multi-agent", …},
"submission_id": "c07aa81d-7b2d-4528-bd26-b0b942d00936",
"created_at": "2026-09-23T15:31:08.8654Z"},
{"id": "2ae0b9d2-96cc-464d-88d6-58acb7811c23", "user_id": "default",
"session_id": "work:project:75db2aa1-a719-4365-a871-824d74b6930a",
"request": {"query": "also can you review my ulip invesments.. i added some new", …},
"submission_id": "47d70eba-145d-4db2-9a35-9bc08a5d3b84",
"created_at": "2026-09-23T15:31:27.53754Z"}
]
}Dead pane — no tmux session for the retained terminal (only an unrelated Sept-19 session; the work session's attach process from 20:42 is orphaned):
$ tmux ls
mlp-muse-query-1789836676715690000: 1 windows (created Sat Sep 19 22:21:18 2026)
$ ps … | grep tmux
76808 … 0:00.01 tmux -CC attach -t mlp-muse-work-project-75db2aa1-a719-4365-a871-82…
Idle CLI — retained muse session 01a0ce94-1149-7842-acdc-c32571a8314c
(dir mtime 19:37 IST; tail is only session.resource_pressure.observed
noise, no turn activity since ~19:37).
Gap census — every log line for the session in 20:42–21:01 is one of: the 20:42:07 settle, one chat-history persist, three timeline echoes. No turn start, no submit, no delivery. The lane went busy with nothing behind it.
Occupancy gate that never clears (conversation_turn_queue.go:184):
if api.sessionTurnInProgress(sessionID) || api.conversationTurnDispatchPending(sessionID) ||
api.hasActiveTurnCancel(sessionID) || api.storedAgentTurnInProgress(sessionID) {
return true
}
if retained, ok := mcpagent.LookupSession(sessionID); ok && retained.ActiveTurnID() != "" {
return true
}
_, retainedRunning := api.retainedMainTurns[sessionID] // ← stuck entry
return retainedRunning-
Promotion validation (root cause). Before promoting
pending[0], drop leading entries whose embedded creation time predates the completing turn'sstartedAt(already in scope at the promotion branch), logging each drop. Anything older than the turn that just completed cannot be live input the CLI is about to answer — legit rapid-fire pendings are seconds old, so this cannot misfire on the real case. If nothing fresh remains, fall through to the existing delete-tracking + release + kick path. -
Watcher fail-after (defense in depth). Add
retainedMainTurnStuckFailAfter(e.g. 60–90 min, a multiple of the 30-min warn threshold so genuine long turns survive); past it, emit a failed stream completion and return. The existingfailedbranch already deletes all four tracking maps, releases the lane, fails pending IDs, and kicks the queue — queued turns then drain and the UI flips to failed-with-reason instead of hanging. Thresholds are already package vars mutated interminal_live_attach_test.go, so this stays testable the same way. -
Dead-pane fast-fail. In the watcher recheck, test
tmux has-sessionforsnapshot.TmuxSession; after N consecutive misses (3, to ride out transient tmux hiccups), fail the turn immediately with reasonretained pane gone. A dead pane can never yield pane-idle or a new durable response, so waiting is pure loss. (The capture-error →handleRetainedMainTurnStreamClosedpath covers some pane death, but this incident captured successfully while the session was gone, so an explicit existence check closes the hole.)
Tests: promotion skips stale pendings and frees the lane; watcher fails after the fail-after threshold (var override); dead-pane fast-fail after consecutive misses while a single transient miss does not fail.
Immediate relief for a jammed session: restart the agent server.
Occupancy is all in-memory; startup recovery (recoverConversationTurnQueue)
resets StartedAt, re-registers queue owners, and kicks — unclaimed
turns in the queue doc then execute normally. Verified safe for this
incident: both turns are intact and unclaimed in the queue doc.
- The stale ID was not re-appended and did not "survive" pruning by accident. The pending list is FIFO and each settle promotes exactly one entry. When a CLI answers several rapid inputs in one response, inputs are appended faster than completions pop them, so the list lags: the 20:18 and 20:35 settles popped older entries and the 20:42 settle reached the 20:00 input. Every promotion after the first batch was a phantom. No caller re-marks a finished execution (all callers pass a fresh ID per request); a guard now refuses to queue an already-finished execution anyway.
- "Rapid inputs answered in one response" is the trigger, not only stale IDs. It creates brand-new phantom pendings too, so the timestamp-based fix #1 would not have caught it, and parsing times out of execution IDs is fragile. Replaced by a promotion evidence gate.
- Why only some providers jam: Claude, Codex, Cursor and Pi report an idle composer, so a phantom promotion settles about 1s later on pane-idle. Muse has no pane-ready signal and relies on a durable final response written after the promotion, which a phantom never produces, so it waited forever. Here the pane had also vanished while the tmux control client stayed attached, so the existing stream-closed path never fired.
agent_go/cmd/server/server.go:
-
Promotion evidence gate. A promoted turn's observer
(
observeRetainedMainTurnStreamMode(..., promoted=true)) must see new CLI output. Without it forretainedMainTurnPromotionEvidenceGrace(75s),settleUnansweredRetainedPromotioncompletes the promoted input and the backlog behind it as answered by the previous response (settled_reason=answered_by_previous_response). It then emits the completion, which releases the lane and kicks the conversation-turn queue. -
Fail-after backstop. After
retainedMainTurnStuckFailAfter(30m) with neither pane-idle nor a durable final response, the turn and its backlog are failed (failRetainedTurnExecutions). The lane is released so queued turns drain, and the chat shows the reason. -
Dead-pane fast-fail. Every
retainedMainTurnPaneCheckInterval(5s) the observer checks the pane (display-message/has-session). Three consecutive missing or dead results fail the turn withretained pane gone; one transient miss does not. -
Guard.
markRetainedMainCodingTurnRunningWithOwnernever queues an execution the tracker already finished (trackedExecutionFinished).
Tests in plat178_retained_promotion_test.go pass with -race, 3 runs:
- finished execution never queued;
- rapid inputs answered together settle after the grace window and release the lane;
- a promoted turn with new output is not settled early;
- fail-after drains the lane and fails the backlog;
- dead pane fails after 3 misses but not after 1;
- real-tmux killed pane releases the lane and fails the backlog.
In the real-tmux test, the existing stream-closed path fired because killing the session also ends the control client. The orphaned-attach case is only covered by the unit test.
The related suites (Retained, LiveAttach, LiveInput, ConversationTurn, Query,
Steer, CodingAgent, ChatSubmission, Durable, Terminal) pass except 4 tests
that fail identically on origin/main (TestProfileQueryModelMustBelong...,
TestAutoPublishedCodingAgentLLMs* ×2, TestWorkshopResolveLLMConfig...).
Not verified:
- A live Muse or Claude retained chat with rapid sends, and a real mid-turn pane kill on a running server.
- Whether an idle Muse TUI repaints on its own. If it does, that output counts as evidence and a phantom falls through to the 30m backstop instead of the 75s gate.
- Not investigating why the tmux pane died in this specific incident — the
transcript's own last entry (
<local-command-stdout>Bye!</local-command-stdout>) suggests an intentional/bye/exit rather than a crash, but the fix needed here is the same regardless of why the pane ends. - The existing tool-registration-race avoidance already documented at
chat_history_routes.go:141-153is untouched — this fix lives entirely in the workflow-builder conversation-display path, a different function than the terminal-pane restore tiers that comment describes.
The original PLAT-178 repair completed build, unit, deployment-health, and live hard-reload verification. The follow-up reconciliation refactor above is now deployed and healthy; it still requires a hard-refreshed fresh retained-turn check before this recurrence can be closed.
-
go build ./...clean. - 2026-09-16 regressions pass:
TestTryDeliverQueryAsLiveInputReactivatesSettledRetainedTmuxproves a confirmed/api/queryretained delivery persists the exact authenticated user/session/message tuple, andTestRefreshLatestBuilderConversationFromPiTranscriptproves recovery reads the isolated working-directory transcript rather than ambient Pi storage. -
TestPiRetainedProgressPreservesMessagesAroundToolsnow includes a real thinking-only assistant record (thinking+ tool call + empty text), proves it is emitted exactly once as progress, and proves it never enters the final-answer message stream. The shared retained-progress and completion watcher suites also pass for all retained providers. - Frontend regression
workflowTabsNeedingHydrationproves an interactive workflow-builder tab with a non-empty capped volatile tail still requests durable history, while a populated read-only schedule remains untouched. The focused session-restore/hydration suite passes (19 tests), along with TypeScript compilation and focused ESLint. - New tests in
claude_native_transcript_sync_test.go: working-directory slug encoding matches the real scheme; text extraction correctly keeps plain-string/.text-block content and dropstool_use/tool_result/thinking; a fail-before/pass-after style test constructs a snapshot frozen at13:51:26plus a native-transcript fixture with two later messages and confirms the merge appends both in order and advancesupdated_atto the transcript's newest timestamp; a no-op test confirms non-claude-code/missing-runtime snapshots are left untouched. -
TestRefreshLatestBuilderConversationIgnoresLiveInputUpdatedAtAsTranscriptCursorreproduces the real ordering gap: persisteduser1,user2withupdated_atafter nativeassistant1, and verifies restore returnsuser1,assistant1,user2,assistant2without duplication. - Merge-level tests cover missing replies between persisted live inputs and repeated identical user messages with an older persisted-only prefix.
-
go test ./cmd/server/...: all new tests pass; the only failures (TestWorkshopResolveLLMConfigExpandsCodingAgentMode,TestStandalonePulseReviewCommandsUsePersistedReviewerPipeline,TestArtifactDriftAuditsTheSchedule) are pre-existing onorigin/mainand unrelated to conversation persistence — confirmed by running the same three tests againstorigin/maindirectly before this change. - Root cause itself was independently re-verified by direct evidence before
any code was written:
conversation_historyJSON's mtime (13:51:26) confirmed frozen while the live session continued (CDP browser call at13:55:04, terminal polling through14:31);appendLiveInputToPersistedChatHistory(chat_history_persistence.go:2934-3009) read directly, confirmed it only appends the human message; the native Claude Code transcript file was located on disk and read directly — 1774 total lines, 155 entries timestamped after13:51:26local (36 user, 63 assistant, 37 attachment, 16 queue-operation, 3 system), ending15:19:59local, proving the "lost" conversation was fully intact and just unread by the restore path.
RTS 13:20 UTC, Work project chat: the server pasted a three-line message into
Claude's tmux pane, but Claude never submitted it. The text vanished and the
composer was left holding two blank lines. The draft check reads only the ❯
line, so it saw nothing; the durable watch correctly marked the message failed
60 s later (red "!"). The re-send was recorded with two leading newlines.
Fixes:
- multi-llm 89a7c49: when the submit check runs out of retries, Claude's transcript decides. If there is no user or queue row for the send within 6 s, the composer is emptied and the message sent once more in the same input slot. Blank lines are also cleared before every paste.
- Frontend: a failed message now shows "Not delivered · Resend" next to it; Resend sends the same text through the chat's normal send path.
Auto-synced from docs/ on main. Edit there, not here.