Skip to content

fix: model switching reliability, lost composer input, stranded tasks after compaction, and intranet TLS - #377

Open
Tom-Ma-Ming wants to merge 7 commits into
agegr:mainfrom
Tom-Ma-Ming:fix/model-switch-and-input-loss
Open

fix: model switching reliability, lost composer input, stranded tasks after compaction, and intranet TLS#377
Tom-Ma-Ming wants to merge 7 commits into
agegr:mainfrom
Tom-Ma-Ming:fix/model-switch-and-input-loss

Conversation

@Tom-Ma-Ming

Copy link
Copy Markdown

Four fixes found while using pi-web against a self-hosted model gateway. Each is an independent commit.

1. Model switching looked like a no-op and needed several tries

Three causes stacked:

  • loadSession cleared the optimistic model unconditionally, so any reload (agent_end, bash settle, reconcile poll) snapped the picker back to the session file — which lags a switch, since pi appends model_change asynchronously and a restarted wrapper replays an older leaf.
  • The dropdown skipped set_model entirely for the entry that already looked active, so once the label went stale there was no way to correct it: clicking the model you wanted did nothing.
  • Nothing was shown between the click and the response. After the wrapper's 10-minute idle destroy, the first switch has to cold-start the agent from the session file, which takes long enough to read as "ignored".

Now the choice is applied optimistically with monotonic switch ids so a slow response cannot overwrite a newer pick, the override is dropped only once a reload agrees, every click re-issues the idempotent set_model, and the picker syncs from live agent state — get_state already returned model, the client just discarded it. Failures revert and surface a notice instead of only reaching the console.

2. Typed text was lost whenever a turn failed

ChatInput clears the composer right after a fire-and-forget onSend, and pi runs prompt() fire-and-forget too, so the POST returns 200 and the failure arrives later as prompt_error. Only EventStreamConnectionError was recovered. Three further gaps:

  • pi writes no session file until the session has an assistant message, so a first-message failure on a fresh session lost the text outright.
  • A steer/follow-up pi accepted but never delivered died with the wrapper — that queue is in-memory only, so agent_end reading no state silently reset it to empty.
  • restoreSubmission read the textarea to decide how to merge, but submitting queues setValue("") and a fast failure can land before React flushes it, so the read saw text that was about to be wiped. This is how the recovery silently bailed on "Timed out connecting to the agent event stream".

The in-flight prompt is now kept and restored from loadSession only when the reloaded transcript no longer contains it, so a failure that did persist the message does not duplicate it. The queue is mirrored client-side and handed back when the agent reports no state. The composer merge uses a functional update, correct in every ordering.

3. Threshold auto-compaction leaves the task stranded

Upstream _checkCompaction documents "Threshold: Context over threshold, compact, NO auto-retry (user continues manually)" and returns hasQueuedMessages(), so with an empty queue the agent loop just ends mid-task and the user has to nudge it by hand.

This commit deliberately departs from that behaviour — worth a maintainer opinion, and easy to drop if you would rather pi-web match the CLI. pi-web now sends the nudge itself, armed only for threshold compaction (reason !== "manual", !willRetry, since the overflow path resumes on its own). Bounded to three consecutive continuations so a task that keeps overflowing cannot quietly spend tokens in a loop; any user message resets the streak.

4. Intranet model endpoints reached by IP fail before the request leaves the process

A provider at https://<ip>:<port> serving a certificate whose SAN only lists a DNS name is rejected with ERR_TLS_CERT_ALTNAME_INVALID, and the UI shows nothing at all — no response, no error. curl -k succeeds, so the endpoint looks healthy.

lib/tls-overrides.ts probes such origins found in models.json, reuses the certificate's SAN name as the TLS servername so the chain and hostname are still fully verified, and falls back to skipping verification for that one origin only if the probe fails. It resolves per-origin connect options that the existing dispatcher's client and pool factories merge in, rather than competing over setGlobalDispatcher. Opt-out and manual overrides via PI_WEB_TLS_AUTO=0, PI_WEB_TLS_SERVERNAME_OVERRIDES, PI_WEB_TLS_INSECURE_HOSTS.

Verified through the real global fetch path: the intranet endpoint answers 200, and https://self-signed.badssl.com is still rejected with DEPTH_ZERO_SELF_SIGNED_CERT, so public verification is not weakened.

Checks

tsc --noEmit, eslint ., 240/240 tests, and next build all pass on top of v0.8.6.

No automated regression tests were added: these are event-ordering and TLS paths, and the existing suite renders statically with no DOM harness available. Manual verification covered each fix.

🤖 Generated with Claude Code

openhands-agent and others added 7 commits August 3, 2026 22:48
…lure

Model switching looked like a no-op and needed several tries. Three causes:
loadSession cleared the optimistic model unconditionally, so any reload
(agent_end, bash settle, reconcile poll) snapped the picker back to the
session file, which lags a switch; the dropdown skipped set_model entirely
for the entry that already looked active, so a stale label could never be
corrected; and nothing was shown between the click and the response, which
after the wrapper's 10-minute idle destroy means a cold agent restart.

Now the choice is applied optimistically with monotonic switch ids so an
out-of-order response cannot win, the override is dropped only once a reload
agrees, every click re-issues the idempotent set_model, and the picker is
synced from the live agent state (get_state already returned the model; the
client discarded it). Failures revert and surface a notice instead of only
reaching the console.

Typed text vanished whenever a turn failed. ChatInput clears the composer
right after a fire-and-forget onSend, and pi runs prompt() fire-and-forget
too, so the POST returns 200 and the failure arrives later as prompt_error —
a path that restored nothing. pi also writes no session file until the
session has an assistant message, so a first-message failure lost the text
outright.

The in-flight prompt is now kept, staged on prompt_error when the model
produced no output, and restored into the composer from loadSession only
when the reloaded transcript no longer contains it. handleSend recovers from
every error rather than just EventStreamConnectionError, a missing session id
throws instead of silently hanging, and steer / follow-up / queued prompts
get the same recovery and notice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… cleared

Three remaining ways a submitted message could vanish.

A steer or follow-up that pi accepted (200) but never delivered died with the
wrapper: that queue lives only in the wrapper's memory and is never written to
the session file, so agent_end reading no state silently reset it to empty
while the composer had already been cleared. The client now mirrors what it
handed to the queue, drops entries once the live queue no longer lists them or
a matching user message is delivered, and restores whatever is left with a
notice when the agent reports no state at all.

restoreSubmission read the textarea to decide how to merge, but submitting
queues setValue("") and a fast failure can land before React flushes it — the
read saw text that was about to be wiped, which is exactly how insertIfEmpty
bailed out and lost the message on "Timed out connecting to the agent event
stream". It composes against the queued state with a functional update
instead, which is correct in every ordering.

A failure on a brand-new session restored its text under the "new:<cwd>"
draft key, and promoting the session swapped the key, resetting the composer
to the promoted session's empty draft. The recovered text is now carried
across that key change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Threshold auto-compaction leaves a task stranded. Upstream this is deliberate
— pi's _checkCompaction documents "Threshold: Context over threshold, compact,
NO auto-retry (user continues manually)" and returns hasQueuedMessages(), so
with an empty queue the agent loop just ends and the user has to nudge it by
hand.

pi-web now sends that nudge. Only threshold compaction arms it: a manual
/compact is the user deliberately stopping, and willRetry means pi resumes the
turn itself on the overflow path, where continuing would double up.
compaction_end can arrive either side of agent_end, so the continuation is
armed by the event and fired from wherever the run settles.

Bounded so a task that keeps overflowing cannot quietly spend tokens in a
loop: three consecutive continuations, then it stops with a notice. Any
message the user sends resets the streak and disarms a continuation they have
already responded to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sending a message produced no response at all: the intranet provider is
reached by IP (https://172.16.111.183:9443) but serves a DigiCert certificate
whose SAN only lists ai.secsign.online, so Node rejected every request with
ERR_TLS_CERT_ALTNAME_INVALID before it left the process. curl -k worked, which
is why the endpoint looked healthy.

The previous generation of this project handled that in lib/tls-overrides.ts,
loaded from instrumentation.ts. This one replaced that instrumentation hook
with the HTTP dispatcher setup and dropped the TLS module entirely.

The module is back, reworked so the two cooperate instead of fighting over
setGlobalDispatcher: it now resolves per-origin connect options that the
existing dispatcher's client and pool factories merge in. Behaviour is
unchanged — probe the peer certificate, reuse its SAN name as the TLS
servername so the chain and hostname are still fully verified, and fall back
to skipping verification for that one origin only if the probe fails.

Verified through the real global fetch path: the intranet endpoint answers
200, and https://self-signed.badssl.com is still rejected with
DEPTH_ZERO_SELF_SIGNED_CERT, so public verification was not weakened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An overflow auto-compaction was observed ending the run instead of resuming
it: context reached 124k of a 128k window, the model emitted one token and
stopped with stopReason "length", pi compacted on the overflow path with
willRetry set, and the session ended at the compaction entry with the wrapper
later reaped as idle.

The continuation deliberately skipped that case, trusting pi's documented
promise that the overflow path resumes the turn itself — so the run that most
needed rescuing was the one it declined to touch. Arm for every auto
compaction now; only a manual /compact still counts as the user stopping on
purpose.

Double-firing was the reason for the original exclusion and remains handled:
the continuation already bails while the run is active, so a turn pi really
did resume is never prompted twice. What that guard could not catch is a
resumed turn that later ends on its own — the arming point now resets the
output marker, and a continuation is skipped when the model produced anything
after the compaction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ever

Two sessions were found wedged with isStreaming and isPromptRunning true and
no output for over ten minutes, one of them still wedged after the shell tool
it was waiting on had already exited. Nothing recovers that state: the turn
never ends, the UI shows a spinner with no explanation, and the session stays
unusable until someone notices and aborts by hand.

The HTTP dispatcher cannot catch it. Its bodyTimeout is an idle timeout
between chunks, so a gateway that holds the stream open — as this one does,
with first-byte latency swinging between 4 and 57 seconds under load — never
trips it.

A wall-clock watchdog covers the gap. Every inner event restarts the clock,
since any event is a sign of life, and the clock only runs while a turn is
actually in flight. When it expires the wrapper checks that nothing else is
genuinely working — no shell command, no compaction, no tool between its start
and end events — and only then aborts and emits an error saying why, so the
turn is recoverable by resending rather than silently dead.

Ten minutes by default, PI_WEB_MODEL_STALL_TIMEOUT_MS to change it, "disabled"
to turn it off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A failing assertion skipped wrapper.destroy(), and rpc-manager's ten-minute
idle timer is not unref'd, so the leaked wrapper pinned the event loop and the
run hung instead of reporting which assertion failed. Moving teardown to
t.after means a regression now shows up as a failure.

Verified by short-circuiting the abort: the wedged-turn test fails and the
other five still pass, so the suite detects the bug it was written for.

Co-Authored-By: Claude Opus 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.

2 participants