agents proxy: opencode M3 — history on re-attach - #1949
Conversation
| // captured from a real server (see the capture doc). No cache: history is | ||
| // fetched per request, and the TUI asks once per attach. |
There was a problem hiding this comment.
this header is stale after the cache commit, it still says "No cache: history is fetched per request", but history() below is cached + single-flight and invalidated on turn end. Worth updating so the file doc matches the behavior
There was a problem hiding this comment.
Updated in the same commit — the header now says replays are cached for the proxy's lifetime (fetched once, single-flighted, warmed at first request, invalidated on turn completion), and the history() doc comment describes the outside-the-lock design.
| f.histMu.Lock() | ||
| defer f.histMu.Unlock() | ||
| if f.histValid { | ||
| return f.hist, nil | ||
| } | ||
| msgs, err := f.fetchHistory(ctx) |
There was a problem hiding this comment.
history() holds histMu for the whole replay (~8s). invalidateHistory() needs that same lock, so if a turn completes while history is still loading, the event loop can wait on that lock until the replay finishes
There was a problem hiding this comment.
Fixed in 83ede4b — the fetch now runs outside histMu. Single-flight is a histFetching flag + done channel (waiters honor their ctx), and a histGen counter catches an invalidation that overlapped a fetch: the overlapped result is still returned to its caller (valid point-in-time read) but not cached, so the next read replays fresh. invalidateHistory() is now only ever a fast lock. Regression test holds a replay mid-stream (via the harness's WaitForHITL gate — HangStreamAfterEvents exempts replay_only streams) and asserts invalidation returns immediately and the superseded fetch doesn't populate the cache.
GET /session/{id}/message is served from a one-shot replay_only
StreamSession pass, reconstructing each hosted run as its user message
(run.started's prompt text — the SSE wire delivers it as data.agent, not
the proto's user_input; both are accepted) and assistant message (token
deltas accumulated; reasoning as its own part). opencode's attach
replays history client-side by fetching this endpoint (?limit=100
observed live), so re-attaching a fresh proxy shows prior turns; the
session list now includes the bridged session whenever it has
replayable history, which is what --continue resumes through.
Ids are minted from turn event times with the real time-encoding, with
monotonic T allocation across turns — two turns sharing a millisecond
would otherwise interleave (caught by test). time.completed comes from
the run.completed event, not the run's max event time — late stragglers
(run.log after completion) otherwise inflate a 2s turn into a 20m badge
(found live). Also stubs GET /session/{id}/diff and /todo (empty
arrays, the TUI's post-turn refresh lookups).
Live-verified: a fresh proxy + `opencode attach --continue` renders
the session's six prior turns with correct prompts, answers, order, and
durations.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
harness-api's replay_only stream lingers ~8s after catching up (measured: 68 events, close at exactly 8.0s both runs), and an attach fetches history twice (session-list gating + the message list), so resume rendered ~16s late. History is now reconstructed once and cached — the mutex doubles as single-flight — warmed in the background off the facade's first request (the TUI's health preflight precedes the attach burst), and invalidated when a live turn completes. Warm-path timings: 8s -> 11ms. The 8s replay-close linger itself is a harness-api behavior worth fixing server-side (it also slows doctl agents logs); tracked outside this PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
a3605c4 to
5899192
Compare
history() held histMu across the whole ~8s replay, and invalidateHistory() needs that same lock — so a turn completing while history was still loading stalled the event loop (and every frame behind it) until the replay finished. The fetch now runs outside the lock: single-flight rides a histFetching flag + done channel (waiters also honor their ctx), and a histGen counter detects an invalidation that overlapped a fetch — the overlapped result is returned to its caller but not cached. Regression test holds a replay mid-stream via the harness's WaitForHITL gate and asserts invalidateHistory returns immediately. Also refreshes the stale file header that still said "No cache: history is fetched per request" from before the cache commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stacked on #1948 (M2 — review that first; this diff is M3 only). Third slice of the opencode facade: re-attaching shows prior turns.
What's here
GET /session/{id}/messageserved from a one-shotreplay_onlyStreamSessionpass over the session's durable event history, reconstructed into the[{info, parts}]shape captured from a real server. opencode replays history client-side —attachfetches this endpoint (?limit=100observed live, honored) — so nothing is pushed on connect and codex's--replayflag machinery has no opencode equivalent.GET /sessionwhen it has replayable history (or was used through this proxy), which is whatopencode attach --continueand the session picker resume through a freshly started proxy. A truly fresh session still lists empty (the M0-captured fresh-attach state).GET /session/{id}/diffand/todostubs (empty arrays) — the TUI's post-turn and resume refresh lookups, previously 404s.Found while verifying live
run.started's prompt text arrives on the SSE wire asdata.agent, not the proto'suser_input— both keys are accepted (with the proto name preferred), so history reconstructs the user side of each turn.time.completedmust come from therun.completedevent, not the run's max event time: late stragglers (run.logafter completion) inflated a 2-second turn into a "20m 1s" duration badge.Testing
limithandling, fresh-session emptiness, failed-turn shape, history-gated session listing.opencode attach --continuerendered all six with correct prompts, answers, order, and durations.Not in this PR
Tool-call parts and real token/cost numbers in history and live turns (M4), permission round-trips (M5), stream reconnect (M6).
🤖 Generated with Claude Code