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
Open
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
loadSessioncleared 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 appendsmodel_changeasynchronously and a restarted wrapper replays an older leaf.set_modelentirely 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.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_statealready returnedmodel, 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
ChatInputclears the composer right after a fire-and-forgetonSend, and pi runsprompt()fire-and-forget too, so the POST returns 200 and the failure arrives later asprompt_error. OnlyEventStreamConnectionErrorwas recovered. Three further gaps:agent_endreading no state silently reset it to empty.restoreSubmissionread the textarea to decide how to merge, but submitting queuessetValue("")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
loadSessiononly 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
_checkCompactiondocuments "Threshold: Context over threshold, compact, NO auto-retry (user continues manually)" and returnshasQueuedMessages(), 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 withERR_TLS_CERT_ALTNAME_INVALID, and the UI shows nothing at all — no response, no error.curl -ksucceeds, so the endpoint looks healthy.lib/tls-overrides.tsprobes such origins found inmodels.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 oversetGlobalDispatcher. Opt-out and manual overrides viaPI_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.comis still rejected withDEPTH_ZERO_SELF_SIGNED_CERT, so public verification is not weakened.Checks
tsc --noEmit,eslint ., 240/240 tests, andnext buildall 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