From afccb38594f425f3cf2e23c1e40f3ca6f34cc571 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 7 Sep 2026 16:48:52 -0400 Subject: [PATCH] refactor(terminal): name the provenance rule the takeover fence hangs off The fence rode the mobile input floor claim, with only a comment tying the two together. The floor is arbitration -- who may write next -- while the fence needs provenance -- who produced the bytes. They agree today, so anyone reweighing the floor would have moved the fence without noticing. isDeliberateHumanInput states the provenance rule on its own terms, and both byte lanes decide with it when they open a write: the claim carries the verdict beside the handle, and settlement records the takeover only when a human produced the bytes. No behavior change -- afterWrite is wired only where the predicate already answers true -- and the rule is now pinned by its own cases, so a future arbitration change has to answer this question again rather than inherit it. --- ...worker-release-input-lane-takeover.test.ts | 21 +++++++- .../terminal/terminal-input-delivery.ts | 51 ++++++++++++++++--- .../methods/terminal/terminal-send-method.ts | 6 +-- 3 files changed, 66 insertions(+), 12 deletions(-) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-input-lane-takeover.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-input-lane-takeover.test.ts index e2f9cfe56b5..46bd20f89e9 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-input-lane-takeover.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-input-lane-takeover.test.ts @@ -1,7 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' import { TERMINAL_SEND_METHODS } from '../../terminal/terminal-send-method' -import { sendTerminalStreamInput } from '../../terminal/terminal-input-delivery' +import { + isDeliberateHumanInput, + sendTerminalStreamInput +} from '../../terminal/terminal-input-delivery' import { isStreamingMethod, type RpcMethod } from '../../../core' import { RuntimeTerminalWriter } from '../../../../runtime-terminal-writer' import { getDefaultWorkspaceSession } from '../../../../../../shared/constants' @@ -324,3 +327,19 @@ describe('a mobile takeover lifts the settled worker resume fence', () => { expect(fenceChanges.at(-1)).toEqual([harness.workerPaneKey, false]) }) }) + +// The fence hangs off provenance, not off the input floor, so this rule is pinned on its own. +describe('which bytes count as a person typing', () => { + const cases: [string, Parameters, boolean][] = [ + ['a phone keystroke', [{ client: MOBILE_CLIENT }, false], true], + ["an agent's terminal send", [{ client: CLI_CLIENT }, false], false], + ['a phone query reply', [{ client: MOBILE_CLIENT, inputKind: 'query-reply' }, false], false], + ['a legacy phone on a mobile-driven pane', [{}, true], true], + ['a clientless send on an idle pane', [{}, false], false] + ] + for (const [name, args, expected] of cases) { + it(`${name} is ${expected ? '' : 'not '}human input`, () => { + expect(isDeliberateHumanInput(...args)).toBe(expected) + }) + } +}) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts index cf039e681c3..e925b43382e 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts @@ -51,6 +51,42 @@ export function resolveMobileFloorClientId( return null } +/** + * Whether these bytes are a person typing, rather than an agent's `terminal send` or the emulator + * answering a device query. + * + * Deliberately its own rule instead of a read of the mobile input floor. The floor is arbitration, + * deciding who may write next; this is provenance, deciding who produced the bytes. They agree + * today, and the orchestration takeover fence hangs off THIS one, so reweighing the floor cannot + * move the fence without someone answering this question again on its own terms. + */ +export function isDeliberateHumanInput( + input: { client?: TerminalViewportClient; inputKind?: 'query-reply' }, + mobileWithoutClientMetadata: boolean +): boolean { + if (input.inputKind === 'query-reply') { + return false + } + if (input.client?.type === 'mobile') { + return true + } + // Pre-refactor mobile builds send no client metadata; the pane's mobile driver, or a mobile + // stream's own kind, is the only remaining evidence of who is at the keyboard. + return !input.client && mobileWithoutClientMetadata +} + +/** One mobile write's floor claim and provenance verdict, decided together before the bytes move. */ +export function newMobileInputWrite( + input: { terminal: string; client?: TerminalViewportClient; inputKind?: 'query-reply' }, + mobileWithoutClientMetadata: boolean +): MobileInputFloorClaimHolder { + return { + handle: input.terminal, + humanInput: isDeliberateHumanInput(input, mobileWithoutClientMetadata), + current: null + } +} + export type TerminalStreamInputOutcome = 'delivered' | 'rejected' | 'failed' export function watchSubscriptionLifetime( @@ -113,7 +149,7 @@ export async function sendTerminalStreamInput( ): Promise { const action = { text: args.text, enter: false, interrupt: false } const clientId = args.isMobile ? args.client?.id : undefined - const floorClaim: MobileInputFloorClaimHolder = { handle: args.terminal, current: null } + const floorClaim = newMobileInputWrite(args, args.isMobile) try { if (!clientId) { const result = await runtime.sendTerminal(args.terminal, action) @@ -142,22 +178,21 @@ export async function sendTerminalStreamInput( export type MobileInputFloorClaimHolder = { handle: string + humanInput: boolean current: ReturnType } /** - * Settle an accepted mobile write: the phone keeps the input floor, and the host records that a - * human is now driving this terminal. - * - * The floor claim is the host's only proof the bytes are deliberate human input — an agent's - * `terminal send` reaches the same methods naming itself a desktop client, and the emulator's own - * query replies never claim the floor — so the takeover fence hangs off exactly this condition. + * Settle an accepted mobile write: the phone keeps the input floor, and if a human produced the + * bytes the host records that they are now driving this terminal. */ export async function settleMobileInputWrite( runtime: OrcaRuntimeService, claim: MobileInputFloorClaimHolder ): Promise { - recordWorkerTerminalUserTakeoverFromInput(runtime, claim.handle) + if (claim.humanInput) { + recordWorkerTerminalUserTakeoverFromInput(runtime, claim.handle) + } await commitMobileInputFloorClaim(claim) } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts index e0702fa30ce..ef666af64bc 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -10,9 +10,9 @@ import { getTerminalSendGuardRefusedReason, isTerminalInputLockedForClient, isTerminalSendGuardNotWritable, + newMobileInputWrite, resolveMobileFloorClientId, - settleMobileInputWrite, - type MobileInputFloorClaimHolder + settleMobileInputWrite } from './terminal-input-delivery' import { updateViewportForClient } from './terminal-viewport-update' import { @@ -181,7 +181,7 @@ export const TERMINAL_SEND_METHODS: RpcAnyMethod[] = [ } } const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client) - const floorClaim: MobileInputFloorClaimHolder = { handle: params.terminal, current: null } + const floorClaim = newMobileInputWrite(params, driver?.kind === 'mobile') const beforeWrite = orchestrationMutation && params.agentPrompt === true ? async (ptyId?: string): Promise => {