Skip to content

fix(terminal): serialize agent-prompt submits per PTY so overlapping sends stop gluing - #14980

Closed
brennanb2025 wants to merge 3 commits into
mainfrom
brennanb2025/fix-pty-send-lock
Closed

brennanb2025 wants to merge 3 commits into
mainfrom
brennanb2025/fix-pty-send-lock

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Status: DRAFT pending an independent review loop. This change qualified for Orca's plan-first gate on
three counts (it changes ordering and failure handling, it crosses a client/host version boundary, and it
sits in a module that was reverted twice) and I ran it out of order — implementation landed before any
reviewer saw a plan. The plan is now written (PLAN-pty-send-lock.md, in this branch) and reviewers are
going over the plan and this diff together. Section 13 of that file and the "Reviewer findings" section
below record what they find and what I reject, with reasons. Not for merge until a reviewer returns clean.

Headline: the seam that was named is not the seam mobile uses

The prior-art review that commissioned this work named writeTerminalAgentPrompt as the write seam.
Mobile native chat never reaches it. In src/main/runtime/rpc/methods/terminal.ts the settled agent-prompt
path is gated on params.client?.type === 'desktop', and mobile sends terminal.send with enter: true and
no agentPrompt — so mobile routes to sendTerminal -> writeTerminalAction, which carries its own
identical body-to-Enter gap
.

Fixing only the named seam would have left the mobile glue window fully open, under a title claiming the
race was fixed, and closed the ticket. That is worse than not fixing it.

This matters past this PR: STA-4492 is the mobile half of the same family, and mobile QA for it was already
reported blocked because the ~500ms window is roughly 10x finer than the emulator can drive. So the mobile
seam had no device-level coverage either — it was neither fixed nor testable. Both are addressed here.

The specific question I want reviewers to attack: is one per-ptyId queue across both entry points
correct, or do the two paths have different ordering or lifetime requirements that a shared queue silently
conflates?

Problem

A terminal submit is not atomic. It writes the message body, waits a frame for the TUI to render it, then writes Enter:

  1. write the body (bracketed paste, or chunked text),
  2. await the render gate — or, with no gate, await the fixed submit delay (500ms; 1500ms on Windows),
  3. write \r.

Nothing serialized step 2 per PTY. Two overlapping sends to the same pane interleaved freely across it: send A's body landed, A parked, send B's body landed on the same agent input line, then A's \r submitted A+B as one line.

So Orca manufactured the glued transcript row that #14935 and #14936 spend their time reconciling. The host produced one row from two sends, and the pending-send matchers downstream had to recognize it after the fact.

Reproduced deterministically before the fix — two terminal.send calls, 499ms apart:

expected [ 'first messagesecond message', '' ]
      to deeply equal [ 'first message', 'second message' ]

Fix — serialize submits per PTY

enqueueTerminalSubmit(ptyId, run) chains each submit behind the previous one for the same ptyId. This reuses the per-PTY async-queue idiom already in this class (enqueueLayout / runLayoutSlot), minus its coalescing — layout targets may supersede each other, prompts may not. Every prompt is delivered, none is dropped. The stored tail absorbs a rejection so one failed send cannot jam the queue, while the caller still sees its own error.

Both gapped write paths, not just one

The write seam exists twice, and the second one is the one that matters for the mobile cluster:

Path Reached by Gap
writeTerminalAgentPrompt desktop agent prompts (agentPrompt: true) render gate, else AGENT_PROMPT_SUBMIT_DELAY_MS
writeTerminalAction mobile native chat, orchestration dispatch, any terminal.send with text + Enter TERMINAL_SEND_SUBMIT_DELAY_MS

Mobile native chat sends terminal.send with enter: true and no agentPrompt, and the desktop settled-prompt path is gated on params.client?.type === 'desktop'. So mobile never reaches writeTerminalAgentPrompt at all — fixing only that path would have left the mobile glue window fully open. Both now share one per-ptyId queue, so a desktop prompt and a mobile send racing on the same pane are also serialized against each other.

Disposition for a concurrent send: queued, deliberately

A send arriving while another is in flight on the same PTY is queued behind it (FIFO), not rejected and not given an interrupt/steer disposition. Reasons, in priority order:

  1. It is the only choice that is invisible to an older client. The RPC contract is byte-identical — both sends still return accepted: true with the same bytesWritten. No new field, no new error code, no new opcode, no capability to negotiate. See the wire section below.
  2. Rejecting loses the user's text. A refusal would need a new refusedReason that shipped clients would render as an unknown failure, and on mobile the composer is cleared at send time — a rejection the client can't classify is a lost message.
  3. Interrupt/steer is not expressible here. It needs a per-send disposition parameter on the wire (capability-negotiated), and Orca's delivery channel is bracketed-paste bytes into a TUI's stdin — there is no way to tell a TUI "steer the current turn instead of appending".
  4. The wait is bounded — with one exception I found and disclose below. Each slot's delays are capped by the render gate's hard timer or the fixed submit delay. The beforeWrite guard awaited inside the slot is capped at 1050ms wall-clock, but a single agent-status read inside that guard has no timeout of its own and can block on SSH/relay. See "Serialization also introduces one new failure mode".

What deliberately does not queue

Only a body→delay→suffix sequence owns a gap to interleave across. Single-write actions stay unqueued:

  • a bare interrupt — Ctrl+C is a cancel; holding it behind a queued send would make it useless. It can land inside another send's gap, which is the correct outcome: it cancels that send's uncommitted body.
  • a bare Enter, and keystrokes with no submit — live typing must not acquire a 500ms lock.

This also means the downstream matchers stay necessary: the user can still type into the TUI by hand while a send is in flight, and shipped clients will keep producing glued rows against updated hosts. This PR removes the source Orca controls.

Test that closes a blocked QA gap

Mobile QA for the glued-send flow was reported blocked: emulator actions cost ~1.2–2.2s each against a ~500ms window, roughly 10× too coarse to land a second send inside the gap. That reasoning is right, and the answer is not a faster emulator.

src/main/runtime/orca-runtime.test.ts gains a deterministic fake-PTY interleaving test on each path. Fake timers park send A inside its own submit gap; send B is started by hand; the bytes the fake PTY controller received are asserted to split into two submitted lines. The race stops being a timing hope and becomes a scheduling decision the test makes — no device, no emulator, no real agent, permanently covered in CI.

Every existing test in this cluster tests the matcher: a glued row is synthesized by hand and fed to the reconciler. Nothing tested that the glue happens. These do, at the seam that creates it.

Four tests total:

  • does not glue two overlapping agent prompt sends into one submitted line
  • does not glue two overlapping terminal.send submits into one submitted line
  • lets a bare interrupt through while a submit holds the per-PTY queue — pins the exemption above
  • releases the per-PTY submit queue when a send fails mid-sequence — pins that a failure cannot jam the queue

Second fix — restoreRejectedDraft merges instead of dropping

restoreRejectedDraft restored a rejected send's text only if the composer was still empty. The intent — never clobber text typed while the rejection was in flight — is right, but the implementation lost data: the composer is cleared at send time and a rejected send never reaches acceptSend, so the rejected text had no other surviving copy. It was not in the composer, there was no pending bubble, and the only trace was a "Message not sent" toast. Over relay/SSH that window is seconds, not milliseconds.

It now merges. Ordering is by authorship: the rejected text was composed before whatever the user typed after it, so it goes first and the newer text follows on its own line (ping + newer edit → ping\nnewer edit). An identical composer is left untouched rather than duplicated. Whitespace is preserved verbatim on both sides — trailing whitespace is load-bearing here, since the host writes it onto the agent's input line.

The rule is extracted to mobile-native-chat-rejected-draft-merge.ts with its own unit tests; use-mobile-native-chat-drafts.ts was exactly at the 300-line cap, and no max-lines disable was added.

Known hole this does NOT close — measured, not hypothesised

A submit that fails after its body is written but before Enter (an authority check throwing, a chunk write
returning false) leaves the body text sitting on the agent's input line. The next queued send then pastes on
top of it. Probed against the fixed code — fail the first submit's Enter, then send again on the same PTY:

submitted: [ "doomed bodynext message" ]     // ONE line, from two sends — post-fix

So, stated precisely: this lock closes the concurrent-send glue path and does not close the failed-send glue
path.
That is pre-existing behaviour — the same scenario glues on main, where the sends also raced on top
of it — but serialization promotes it to the only remaining Orca-manufactured glue path.

It is not fixed here because the obvious fix is not obviously safe: clearing the input line (Ctrl+U) on
failure would also erase text the user may have typed into the TUI, and when the failure was
terminal_not_writable the clear write fails too. This is the open question I most expect to be blocking, and
it is question 1 of 5 in the plan.

Serialization also introduces one new failure mode

beforeWrite is awaited inside the queue slot. Its guard is bounded by a 1050ms wall-clock deadline, but a
single getTerminalAgentStatus call inside that loop has no timeout of its own, and on SSH/relay it can
block on a real foreground-process read. Before this change a stalled guard blocked only its own send; now
it blocks every subsequent send to that PTY.
Bounded queue depth and a per-slot watchdog are open questions
2 and 1 in the plan.

Relationship to #14935 and #14936

All three are complementary. This PR sits upstream of both and blocks neither.

Remote wire compatibility

docs/reference/remote-wire-compatibility.md reviewed. No wire change. The fix is entirely host-side inside OrcaRuntimeService:

  • Rule 1 (new optional field) — n/a, no field added.
  • Rule 2 (new opcode) — n/a, no opcode added.
  • Rule 3 (host publishes different content) — the host publishes the same response fields with the same values. A send that previously succeeded still succeeds; nothing that previously proceeded is now refused. This is exactly why the disposition is queue rather than reject: a rejection would be a Rule 3 change that an older client could not classify.

The one observable difference for an old client is latency: a second concurrent send's ack arrives after its predecessor drains, up to one submit delay later (~500ms, 1.5s on Windows, or the render gate's hard bound). Mobile's send deadline is 15s, so this is immaterial at the concurrency this fixes; a pathologically deep queue is bounded by that budget, and an ack-lost send already has the holdUnconfirmedSend path rather than a false "not sent". An older host paired with an updated client is simply unfixed — it keeps gluing, and the downstream matchers handle it as they do today.

SSH, relay and folder workspaces: the queue is keyed on ptyId inside the runtime that owns the PTY, which is the host in every topology — local, SSH, and relay. Serializing there covers all clients, including ones that never update. Nothing here is worktree-shaped, so folder workspaces behave identically.

Verification

Gate Result
orca-runtime.test.ts (full file) 1166 passed, 1 skipped
mobile session suite 1127 passed
terminal / RPC / orchestration suites 444 passed
typecheck (node + cli + web) clean
mobile tsc --noEmit clean
oxlint --deny-warnings on changed files clean (linter liveness confirmed by injecting a violation — oxlint is silent when clean)
lint:react-doctor no findings on changed files
check:max-lines-ratchet OK, no new bypasses
audit:code-quality:native clean
oxfmt applied

Non-vacuity — production reverted, tests kept: 3 RED.

  • does not glue two overlapping agent prompt sends into one submitted line
  • does not glue two overlapping terminal.send submits into one submitted line
  • merges a rejected send with newer edits instead of dropping either

0 failed merely on a missing export. TERMINAL_SEND_SUBMIT_DELAY_MS and the mobile-native-chat-rejected-draft-merge module were deliberately retained during the revert so nothing failed on imports.

Two runtime tests stay green under the revert by design — they are guards for the deliberate exemptions (bare interrupt passes through; a failed send releases the queue), not regression tests for the lock. The 6 merge-module unit tests likewise stay green because the module is intact; the hook test is what proves the wiring.

Two unrelated tests (orchestration-creator-authority-performance, mobile-image-base64-accumulator) failed only under CPU contention from parallel suite runs and pass in isolation.

Reviewer findings

Populated by the review loop; mirrored in PLAN-pty-send-lock.md section 13. Records what was accepted,
what was rejected, and why.

Round 1 (in progress).

…sends stop gluing

A submit writes its body, waits a frame for the TUI to render it, then writes
Enter. That wait was not atomic, so two overlapping sends to one PTY interleaved
across it and their bytes landed on ONE agent input line — Orca manufacturing
the merged transcript row the pending-send matchers then have to recognize.

Serialize submits per ptyId, reusing the `enqueueLayout` shape without its
coalescing: every prompt must be delivered, none may be dropped. A send that
arrives while another is in flight is queued behind it, so the RPC contract is
unchanged for every client.

Covers both gapped write paths: `writeTerminalAgentPrompt` (desktop agent
prompts) and `writeTerminalAction` when text is submitted with Enter, which is
the path mobile native chat takes. Single-write actions — a bare interrupt, a
bare Enter, keystrokes with no submit — stay unqueued so Ctrl+C and live typing
are never held behind a queued send.

Also: `restoreRejectedDraft` merges the rejected text with anything typed since
instead of dropping it. The composer is cleared at send time and a rejected send
never reaches `acceptSend`, so skipping left no surviving copy of the message.
@brennanb2025
brennanb2025 marked this pull request as draft August 17, 2026 00:08
@coderabbitai

coderabbitai Bot commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The mobile session now merges rejected draft text with newer composer edits and avoids duplicate restoration. Tests cover empty drafts, whitespace, duplicate text, and tab isolation. The terminal runtime now serializes submit actions per PTY, routes agent prompts through the same queue, preserves direct non-submit actions, and uses a shared 500 millisecond delay constant. Tests cover overlapping submissions, interrupts, and queue recovery after failed writes.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the changes and testing in detail, but it omits required template content, including a linked issue, visual proof or N/A, checklist, and author sections. Complete the required template sections, especially the Linked Issue, Visual Proof or N/A explanation, checklist, and author information.
✅ Passed checks (4 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely identifies the primary change: serializing terminal submits per PTY to prevent overlapping sends from being combined.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (4)
mobile/src/session/mobile-native-chat-rejected-draft-merge.ts (1)

1-11: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Condense the implementation comment.

Keep only the non-obvious ordering rule. The current comment is too long for this small helper.

As per coding guidelines: “Comments must be concise, non-obvious, and brief—prefer one line.”

Proposed change
-/**
- * Restoring the text of a send the host definitely rejected.
- *
- * Merge, never skip. The composer is cleared at send time, and a rejected send
- * never reaches `acceptSend`, so the rejected text has no other surviving copy —
- * declining to restore it because the user has since typed again loses the
- * message outright. Over relay/SSH that window is seconds, not milliseconds.
- *
- * Ordering is by authorship: the rejected text was composed before whatever was
- * typed after it, so it goes first and the newer text follows on its own line.
- */
+/** Restore rejected text before later composer edits. */

Source: Coding guidelines

src/main/runtime/orca-runtime.test.ts (3)

17125-17128: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Shorten the block comment.

The comment explains the test intent in four narrative lines. Reduce it to one line that states the non-obvious fact: fake timers are required to interleave sends inside the submit gap.

As per coding guidelines: "Comments must be concise, non-obvious, and brief—prefer one line; do not explain obvious behavior or walk through code."

♻️ Proposed comment trim
-  // Why: these four cover the glue window at the seam that creates it. The flow
-  // is ~500ms wide, roughly 10x finer than a device automation step, so it is
-  // only reachable deterministically here — fake timers park a send inside its
-  // own submit gap and start the next one by hand.
+  // Fake timers are the only deterministic way to park a send inside its submit gap.

Source: Coding guidelines


17217-17218: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert the submit marker over the joined writes.

writes is a string[], so expect(writes).not.toContain(AGENT_PROMPT_SUBMIT) checks array membership. The assertion only holds if the runtime writes the submit marker as its own element. If the runtime ever appends the marker to the body write, the test passes even when the submit already happened. Assert on the joined string to make the check independent of write chunking.

♻️ Proposed assertion change
-      expect(writes).toContain('\x03')
-      expect(writes).not.toContain(AGENT_PROMPT_SUBMIT)
+      expect(writes.join('')).toContain('\x03')
+      expect(writes.join('')).not.toContain(AGENT_PROMPT_SUBMIT)

17136-17147: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the repeated fake PTY setup.

The four tests repeat the same setPtyController block and terminal creation. Extract one local factory, for example createRecordingPtyRuntime(store), that returns the runtime, the handle, and the recorded writes. Keep the writable flag as a parameter so the failure test can still reject writes.

Also applies to: 17170-17181, 17199-17210, 17230-17245


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f52b9201-ce2c-4a50-b761-03c9a9fd1e3d

📥 Commits

Reviewing files that changed from the base of the PR and between c73e5a2 and 985c26d.

📒 Files selected for processing (7)
  • mobile/src/session/mobile-native-chat-rejected-draft-merge.test.ts
  • mobile/src/session/mobile-native-chat-rejected-draft-merge.ts
  • mobile/src/session/use-mobile-native-chat-drafts.test.ts
  • mobile/src/session/use-mobile-native-chat-drafts.ts
  • src/main/runtime/orca-runtime.test.ts
  • src/main/runtime/orca-runtime.ts
  • src/shared/terminal-input.ts

Included review availability: Your plan includes up to 10 reviews per rolling hour; 1 remains after this review.

setDrafts((previous) =>
(previous[origin.draftKey] ?? '') === '' ? { ...previous, [origin.draftKey]: text } : previous
)
setDrafts((previous) => mergeRejectedDraftInto(previous, origin.draftKey, text))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the restoreRejectedDraft contract comment.

Line 66 says restoration is skipped when newer edits exist. This callback now merges the rejected text before those edits. Update the comment to state the new behavior.

Proposed change
-  /** Put the text back after a definite rejection, unless newer edits exist. */
+  /** Restore rejected text before newer composer edits after a definite rejection. */

Comment on lines +18377 to +18401
private writeTerminalAction(
ptyId: string,
action: { text?: string; enter?: boolean; interrupt?: boolean },
payload: string,
options: {
beforeWrite?: (ptyId: string) => void | Promise<void>
reserveWrite?: (ptyId: string) => void
afterWrite?: (ptyId: string) => void | Promise<void>
suffixFailureError?: string
} = {}
): Promise<void> {
const isSubmit =
typeof action.text === 'string' && action.text.length > 0 && Boolean(action.enter)
// Why: only a body→delay→suffix sequence owns a gap to interleave across.
// Single-write actions — a bare interrupt, a bare Enter, keystrokes with no
// submit — stay unqueued so Ctrl+C and live typing are never held behind a
// queued send.
if (!isSubmit) {
return this.runTerminalAction(ptyId, action, payload, options)
}
return this.enqueueTerminalSubmit(ptyId, () =>
this.runTerminalAction(ptyId, action, payload, options)
)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- relevant runtime implementation ---'
sed -n '18330,18445p' src/main/runtime/orca-runtime.ts
printf '%s\n' '--- action type and terminal send callers ---'
rg -n -C 8 'RuntimeTerminalSend|sendTerminal|writeTerminalAction|writeTerminalAgentPrompt|enqueueTerminalSubmit' src/main/runtime/orca-runtime.ts src -g '*.ts' -g '*.tsx'

Repository: stablyai/orca

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- runtime implementation ---'
sed -n '18350,18440p' src/main/runtime/orca-runtime.ts
printf '%s\n' '--- focused declarations and call sites ---'
rg -n -C 5 --glob '*.ts' --glob '*.tsx' \
  'type RuntimeTerminalSend|interface RuntimeTerminalSend|RuntimeTerminalSend|sendTerminal\s*\(|writeTerminalAction\s*\(' \
  src/main/runtime src/shared src/renderer | head -n 500

Repository: stablyai/orca

Length of output: 43000


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- terminal.send input schema and flag handling ---'
sed -n '280,365p' src/main/runtime/rpc/methods/terminal.ts
sed -n '1340,1405p' src/main/runtime/rpc/methods/terminal.ts
printf '%s\n' '--- action construction and payload semantics ---'
rg -n -C 8 'buildSendPayload|terminal\.send|interrupt.*enter|enter.*interrupt' src/main/runtime src/shared docs README.md -g '*.ts' -g '*.tsx' -g '*.md' 2>/dev/null | head -n 400

Repository: stablyai/orca

Length of output: 35670


Serialize text-plus-interrupt terminal actions.

When action.text is non-empty and action.interrupt is true without action.enter, runTerminalAction still waits between the body and suffix, but isSubmit bypasses the per-PTY queue. Include action.interrupt in the queue condition.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

This older combined patch can be closed while the remaining overlapping-send problem is carried into the current terminal writer. Failed-message draft restoration already landed in #25149.

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.

1 participant