Repository navigation
runLoop fails to exit when client-generated messageID has clock skew ahead of server, causing infinite continuation with <system-reminder> wrap #28618
Description
Description
Symptom: When the webui/desktop client and the opencode server run on different machines and the client's clock is ahead of the server's, every prompt triggers a spurious second LLM round-trip after the first response finishes cleanly. In the UI a fresh "thinking…" indicator appears below the completed assistant message and a new (cancellable) request is sent.
Root cause: The runLoop exit condition in packages/opencode/src/session/prompt.ts:1268-1276 uses lexicographic comparison of message IDs (lastUser.id < lastAssistant.id) to decide whether the user message predates the assistant message. Message IDs encode Date.now() in their first 12 hex chars (packages/opencode/src/id/id.ts:51-69). The webui passes a client-generated messageID to session.promptAsync (packages/app/src/components/prompt-input/submit.ts:155-162), so the user message's ID is timestamped with the browser's clock while the assistant message is timestamped with the server's clock. If the browser is even a few hundred ms ahead of the server, lastUser.id sorts after lastAssistant.id, the exit condition fails, the loop enters iteration 2, step++ makes step > 1, and prompt.ts:1400-1416 wraps the user message in <system-reminder>The user sent the following message: …</system-reminder> and fires another API request as if a fresh user message had just arrived.
Evidence from a reproduction (opencode 1.15.5, c5y/claude-opus-4-7-fast, web client on host machine, server in Docker on another host):
| Message | ID | Timestamp encoded in ID | DB time_created |
Note |
|---|---|---|---|---|
| user | msg_e4980c734001BQGJCPTvfloq9Y |
1779349636916 | 1779349634300 | client-generated, +2616ms ahead |
assistant (finish=stop) |
msg_e4980bda30015NAyO5tNmnGO1U |
1779349634467 | 1779349634467 | server-generated |
| assistant (aborted, the spurious 2nd request) | msg_e4980eab3001KzEUq61ccvKyuG |
1779349646003 | 1779349646003 | server-generated |
Lexicographic order: bda3 < c734 ⇒ lastUser.id < lastAssistant.id is false even though chronologically the user message was first.
Matching log excerpt:
07:47:14 session.prompt step=0 loop ← 1st iteration starts
07:47:14 llm ... claude-opus-4-7-fast stream ← 1st API call (succeeds, end_turn)
07:47:25 session.prompt step=1 loop ← 2nd iteration entered (BUG)
07:47:33 llm ... claude-opus-4-7-fast stream ← 2nd API call fired (with <system-reminder> wrap)
07:47:36 session.prompt cancel ← user manually cancelled
The second-call request body has messages = [ user (wrapped in <system-reminder>), assistant ], confirming step > 1 && lastFinished was true.
Suggested fix: stop relying on the lexicographic order of (potentially client-generated) ID timestamps to determine intra-session message ordering. Use the server-recorded time_created field, or track "messages created during this runLoop invocation" explicitly, or generate the user messageID server-side and ignore any client-provided one.
Plugins
oh-my-openagent@4.2.3, opencode-pty, @nick-vi/opencode-type-inject
OpenCode version
1.15.5
Steps to reproduce
- Run opencode server in a Docker container on host A:
opencode serve --hostname 0.0.0.0 --port 4096 - Open the web UI in a browser on host B (different machine).
- Introduce a clock skew of ≥ ~200ms with host B ahead of host A. Easy way: on host A run
sudo date -s "$(date -u -d '3 seconds ago')"(or temporarily stop NTP and let drift accumulate). Verify withdate -uon both machines. - In the web UI, start a brand-new session and send any short prompt (e.g.
你的 system prompt 里有什么/what is in your system prompt). Pick a model whose response naturally finishes withstop_reason: end_turnand uses no tools. - Observe: after the assistant finishes streaming, a new "thinking…" indicator appears below it and a second API request is fired with the original user message wrapped in
<system-reminder>The user sent the following message: … Please address this message and continue with your tasks.</system-reminder>.
Expected: runLoop exits after the first response (finish=stop, no tool calls, single user message in session) — no second request.
Actual: runLoop iterates once more, sends a duplicate request with the system-reminder wrap.
The bug disappears as soon as the clocks are re-aligned (e.g. sudo chronyc -a makestep on host A).
Screenshot and/or share link
No response
Operating System
Debian 13 docker container + msedge 148.0.3967.70 as client
Terminal
No response
Activity
github-actions commentedon May 21, 2026
This issue might be a duplicate of existing issues. Please check:
SessionPrompt.runuses lexicographic ID comparison instead of transcript order/time, which breaks with custom or client-generated messageIDsopencode webremotely (with workaround) #18807: Same bug specifically with remoteopencode weband clock skew between browser and server (now closed)kheerand commentedon May 24, 2026
Confirmed hitting the same bug from the local single-machine angle — no client/server split, no clock skew, just millisecond-resolution ID collisions on the same host.
I filed #28986 before finding this issue. Adding my quantification here since it may help validate the fix:
Local collision evidence
The same
sA.id < L.idfailure happens even when everything runs on one machine. The ID generator (ascending()) produces non-monotonic IDs when messages are created within milliseconds of each other.Example (same-host, same process)
msg_e55166ef1001...msg_e55166ed1001...56ms apart, but the user message ID prefix
e55166efsorts after the assistant ID prefixe55166ed. Exit condition fails, loop continues.Database quantification (opencode 1.15.6, local SQLite)
Measured from the full session database:
finish: "stop"and zero tool calls, but the loop continuesWhat it looks like to the user
Three consecutive AI responses to a single user question — the AI treats its own previous text as new context and continues.
The suggested fix is the same as noted here: use
time_createdtimestamps instead of ID ordering in the exit condition. Happy to test a fix build if one lands.Spanjer1 commentedon May 26, 2026
I encounter this as well with
@openchamber/web->opencode serveon a separate host. Server clock was a couple minutes behind the browser, agent keeps responding without ending. The fix for me was just making sure the clock is in sync.ualtinok commentedon Jul 12, 2026
Confirmed on
v1.17.18throughsession.promptAsyncagainst the live server. This reproduction requires no clock skew: an external plugin supplied content-hash message IDs that satisfy the public schema but do not preserve OpenCode's ordering.Evidence
The first admitted user message used this caller-supplied ID:
It is 20 characters long. OpenCode-generated messages in the same session used the normal 30-character monotonic form, for example:
The custom
msg_f923...sorts after every generatedmsg_f55...message. The assistant repeatedly finished withfinish="stop", butlastUser.id < lastAssistant.idremained false.The plugin's durable execution ledger and server logs agreed exactly:
promptAsynccall entered one run loop with contiguous steps0..56, then the session was aborted.msg_614a2f7eceac0ea0and entered one run loop with contiguous steps0..17, then it was aborted.The second ID sorts below the first custom ID. Because
MessageV2.latest()selects the user with the lexicographically greatest ID, the first injected user remained "latest" even after the second injection and ordinary user messages arrived. Every generated assistant stayed parented to the first injected message.This rules out request retries or runner splitting: exactly two admitted calls produced exactly two runaway loops.
Current source contract
On current
dev:MessageIDvalidation only requires themsgprefix.MessageV2.latest()selects messages using lexicographic ID order.SessionPrompt.runLoop()useslastUser.id < lastAssistant.idas its terminal exit condition.The API therefore accepts IDs that violate an undocumented chronology invariant relied upon by the runtime.
Impact and mitigation
This produced repeated paid provider calls after terminal answers and continued until explicit abort. The plugin mitigation is to stop forwarding caller-supplied IDs and let OpenCode generate them.
Host-side, opaque IDs should not determine transcript chronology. Binding completion to
parentIDor a server-owned sequence would also cover the clock-skew case originally reported here; validating only the ID length/shape would not.github-actions commentedon Sep 11, 2026
To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.