Files
orca/src/shared/agent-session-host-authority.ts
T
Brennan BensonandMerge Sim 8999a00281 refactor(native-chat): give each structured dispatch state exactly one meaning (#20133)
* refactor(native-chat): give each structured dispatch state exactly one meaning

`unknown` meant five different things. Only one of them was genuine
ambiguity.

A transport write that the provider's input pump never took is provably
undelivered -- which is what `rejected` already means. It was recorded as
`unknown` anyway, and a one-entry allowlist then existed solely to teach
Retry that this particular `unknown` was safe to re-deliver.

Collapsing that case into `rejected` deletes the allowlist and turns a
predicate into an invariant: Retry never re-delivers an `unknown`, with no
exception to reason about. The four states now each assert one thing --
`pending` written and awaiting, `accepted` the provider has it, `rejected`
provably did not happen, `unknown` genuinely cannot tell.

A fail-closed guard is the right default here because the asymmetry is
severe: refusing a legitimate retry costs the user a retype, while allowing
an illegitimate one sends the model a second copy of their message.

Also fixed, found while auditing every reader of `rejected`:

- The renderer printed `submission.reason` verbatim, so a broken pipe put
  the internal token `provider_write_failed: broken pipe` on screen in
  destructive red. The journal reason is unchanged -- it is the durable
  evidence and the transport-versus-content discriminator -- but the screen
  now gets copy that names the cause and says the message is safe to
  resend. Content rejections still show the provider's own words.
- The fallback copy "Message was not accepted" read as a content refusal.
  A null reason now yields "Message was not sent.", which asserts only what
  every rejection shares.
- A refused worker-start preamble threw a plain Error out of the dispatch
  path. It now throws `OrchestrationError('dispatch_preamble_undelivered')`
  so a coordinator can tell "we could not send it" from "we sent it and
  something else broke" without parsing prose. Retain/discard behaviour is
  unchanged; only the verdict's legibility improves.

Two behaviours improve as a consequence rather than by design: a provably
undelivered message no longer blocks conversation commands, and no longer
leaves the session reading as "working" in chat and in every session list.

Not addressed here, and named rather than implied: a message left `unknown`
by a dead child or a host restart still has no recourse but retyping. The
restart reconciler that would decide those on evidence is written and has
never had a production caller. Parking the refused entry instead would
reintroduce the head-of-queue wedge removed in #19863, so it is not an
option.

Note for whoever edits `journal-reducer.ts` next: it sits at 297 of its 300
counted lines. The next statement added there needs a split, not a shave.

* fix(native-chat): close two gaps review found in the rejection taxonomy

Both are narrow and both were real.

A journal written before a refused write became `rejected` still holds that
submission as `unknown` with the transport marker. The predicate this change
replaced excluded exactly that shape from provider-echo matching; the
state-only check that replaced it does not, so on replay such a row could
claim the echo of a later, genuinely delivered send of the same text and
attach the delivery to the wrong message. Fail-closed still prevented any
re-delivery, so nothing duplicated — but the wrong submission was credited.
Replay now excludes the legacy shape too.

And the content-versus-transport split had a third case neither side covers:
a local capacity refusal is neither the provider explaining itself nor a
frame that failed to leave. It fell through to the verbatim branch, so
`claude structured dispatch queue is full` reached the screen — the same
class of leak this change set out to fix, one reason short of being caught.
Internal reasons now get copy; only a provider's own words are shown as
written.

Each is pinned by a test that fails with its guard reverted and passes with
it restored.

* fix(native-chat): preserve dispatch refusal across clients

* fix(native-chat): rotate immediately rejected retries

* docs(native-chat): correct rejection taxonomy reference

* docs(native-chat): align mobile retry comment

* docs(native-chat): clarify unknown replay semantics

* fix(native-chat): keep a mobile send's operation id when delivery is unknown

Mobile released the retained operation id whenever a send came back
`unknown`, so the user's next send of the same text went out under a fresh
id. A fresh id has no ledger row, so the host treats it as a first delivery
and dispatches it -- even though `unknown` is the one answer that says the
provider may already have the message. That is the duplicate this branch
exists to remove, reintroduced on the client that has no outbox.

Which case that was matters. Mobile only ever sees `unknown` from ack-loss
(`isRpcDeliveryUnknown`: "the host may have processed it and only the ack
was lost"), because the mapper reported every `ok` result as `accepted`
without reading `dispatchState`. So the rotation fired exclusively where
delivery was ambiguous and never where it was provably refused, which is
the inverse of the rule this branch establishes.

Retaining the id is what makes a retry safe, and it costs no liveness:
`performSend` answers a second request under a recorded id from the journal
and never puts it back on the wire, so a reused id delivers when nothing
landed and replays when something did. Rotating can only ever add a second
copy. The retention stays bounded by the host's admission window, which
`retainStructuredSessionOperationId` already enforces.

`retryUnknown` goes with it: the host ignores it for delivery, and all it
does is skip the cached answer to re-read the same row.

Keeping the id exposes what the rotation was hiding, so fix that too: a
replayed `unknown` comes back `ok`, and mobile called it `accepted` and
cleared the composer as if the message had landed. `dispatchState` now
decides, in one pure function:

  accepted/pending  sent, and the id is spent
  rejected          provably did not happen and terminal in the reducer, so
                    reusing the id could only replay that rejection: spent,
                    and the next attempt is a first delivery under a new id
  unknown           keeps its id

Reading `dispatchState` at all is a pre-existing defect, fixed here because
the false "sent" cannot be removed without it, and scoped to the send path.
`mutate`'s rotation for prompt/option/cancel plans is untouched. The
rejection copy is the desktop's notice, so an internal reason
(`provider_write_failed: ...`) still never reaches a person.

Tests: the hook test that was flipped to assert a rotated id now pins the
opposite -- one id across an ack-loss and two `unknown` replays, each
reported `unknown` rather than `accepted`. The send fixture grew the durable
submission row a real host returns; without it every send test asserted
against a shape that cannot express the bug.

* fix(native-chat): enforce fail-closed structured send replay

* fix(native-chat): align retry and mobile RPC contracts

* fix(native-chat): keep transient admissions retryable

* test(tab-bar): expand nested create menu in harness

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-13 13:46:57 -07:00

214 lines
6.8 KiB
TypeScript

import {
hasUnsafeProviderSessionIdChars,
isResumableTuiAgent,
type AgentProviderSessionMetadata,
type ResumableTuiAgent
} from './agent-session-resume'
import type { RuntimeTerminalCreate, RuntimeTerminalPresentation } from './runtime-types'
import { isTerminalLeafId } from './stable-pane-id'
import { isValidTerminalTabId } from './terminal-tab-id'
import type { TuiAgent } from './tui-agent'
export { AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY as AGENT_SESSION_HOST_AUTHORITY_CAPABILITY } from './protocol-version'
export const AGENT_SESSION_RPC_ERROR_CODES = [
'agent_session_identity_required',
'agent_session_conflict',
'agent_session_resume_not_authorized',
'agent_session_exited_during_start',
'agent_session_claim_unavailable',
'agent_session_ownership_unknown',
'agent_session_checkpoint_stale',
'agent_session_operation_invalid',
'agent_session_operation_conflict',
'agent_session_operation_expired',
'agent_session_operation_capacity',
'agent_session_legacy_required',
'execution_owner_reconciling',
'execution_owner_unavailable'
] as const
export const AGENT_SESSION_CLAIM_DIGEST_VERSION = 1 as const
export const AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION = 2 as const
export const AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION = 1 as const
export const AGENT_SESSION_OPERATION_FUTURE_SKEW_MS = 5 * 60 * 1000
export const AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS = 24 * 60 * 60 * 1000
/** Oldest an operation can be when admitted, plus the host tombstone lifetime after admission. */
export const AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS =
AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS * 2 + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS
const AGENT_SESSION_OPERATION_ID_PATTERN = /^(\d{13})-[0-9a-f]{32}$/
export function parseAgentSessionOperationTimestamp(operationId: string): number | null {
const match = AGENT_SESSION_OPERATION_ID_PATTERN.exec(operationId)
if (!match) {
return null
}
const timestamp = Number(match[1])
return Number.isSafeInteger(timestamp) ? timestamp : null
}
const BASE64URL_RE = /^[A-Za-z0-9_-]+$/
const SHA256_BASE64URL_LENGTH = 43
function isBoundedWireString(value: unknown, maxLength: number): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= maxLength &&
!hasUnsafeProviderSessionIdChars(value)
)
}
export type AgentSessionSurfaceBinding = {
worktreeId: string
tabId: string
leafId: string
terminalHandle: string
}
export type AgentSessionExecutionClaim = {
digestVersion: typeof AGENT_SESSION_CLAIM_DIGEST_VERSION
keyId: string
identityDigest: string
worktreeScopeDigest: string
agent: ResumableTuiAgent
}
export type AgentSessionOwnerBinding = {
claim: AgentSessionExecutionClaim
generation: string
phase: 'reserved' | 'live'
ptyId: string
surface: AgentSessionSurfaceBinding
}
export type AgentSessionClaimedSpawnResult = {
disposition: 'created' | 'adopted'
owner: AgentSessionOwnerBinding
}
export type AgentLaunchPreferences = {
model?: string
effort?: string
mode?: string
}
export type AgentPromptDelivery = 'auto-submit' | 'draft'
export type RuntimeEnsureAgentSessionRequest =
| {
kind: 'automatic'
sleepingCheckpointId: string
presentation?: RuntimeTerminalPresentation
}
| {
kind: 'explicit'
worktree: string
agent: ResumableTuiAgent
providerSession: AgentProviderSessionMetadata
ompResumeFilePath?: string
/** Explicit client override. Omission keeps launch defaults host-owned. */
agentArgs?: string | null
launchPreferences?: AgentLaunchPreferences
presentation?: RuntimeTerminalPresentation
placement?: { tabId?: string; leafId?: string }
}
export type RuntimeEnsureAgentSessionResult = {
terminal: RuntimeTerminalCreate
disposition: 'created' | 'adopted'
}
export type RuntimeCreateAgentSessionRequest = {
clientOperationId: string
worktree: string
agent: TuiAgent
prompt?: string
promptDelivery?: AgentPromptDelivery
/** Explicit client override. Omission keeps launch defaults host-owned. */
agentArgs?: string | null
launchPreferences?: AgentLaunchPreferences
startupCwd?: string
presentation?: RuntimeTerminalPresentation
placement?: { tabId?: string; leafId?: string }
viewMode?: 'terminal' | 'chat'
}
export type RuntimeCreateAgentSessionResult = {
terminal: RuntimeTerminalCreate
disposition: 'created' | 'replayed'
}
export type RuntimeAgentSessionRpcCaller = {
clientId?: string
clientKind?: 'mobile' | 'runtime'
signal?: AbortSignal
}
export function isAgentSessionExecutionClaim(value: unknown): value is AgentSessionExecutionClaim {
if (typeof value !== 'object' || value === null) {
return false
}
const claim = value as Partial<AgentSessionExecutionClaim>
return (
claim.digestVersion === AGENT_SESSION_CLAIM_DIGEST_VERSION &&
isBoundedWireString(claim.keyId, 128) &&
BASE64URL_RE.test(claim.keyId) &&
typeof claim.identityDigest === 'string' &&
claim.identityDigest.length === SHA256_BASE64URL_LENGTH &&
BASE64URL_RE.test(claim.identityDigest) &&
typeof claim.worktreeScopeDigest === 'string' &&
claim.worktreeScopeDigest.length === SHA256_BASE64URL_LENGTH &&
BASE64URL_RE.test(claim.worktreeScopeDigest) &&
isResumableTuiAgent(claim.agent)
)
}
export function isAgentSessionSurfaceBinding(value: unknown): value is AgentSessionSurfaceBinding {
if (typeof value !== 'object' || value === null) {
return false
}
const surface = value as Partial<AgentSessionSurfaceBinding>
return (
isBoundedWireString(surface.worktreeId, 4096) &&
isBoundedWireString(surface.tabId, 512) &&
isValidTerminalTabId(surface.tabId) &&
typeof surface.leafId === 'string' &&
isTerminalLeafId(surface.leafId) &&
isBoundedWireString(surface.terminalHandle, 128) &&
surface.terminalHandle.startsWith('term_') &&
BASE64URL_RE.test(surface.terminalHandle)
)
}
export function isAgentSessionOwnerBinding(value: unknown): value is AgentSessionOwnerBinding {
if (typeof value !== 'object' || value === null) {
return false
}
const owner = value as Partial<AgentSessionOwnerBinding>
return (
isAgentSessionExecutionClaim(owner.claim) &&
isBoundedWireString(owner.generation, 128) &&
(owner.phase === 'reserved' || owner.phase === 'live') &&
isBoundedWireString(owner.ptyId, 4096) &&
isAgentSessionSurfaceBinding(owner.surface)
)
}
export function isAgentSessionClaimedSpawnResult(
value: unknown
): value is AgentSessionClaimedSpawnResult {
if (typeof value !== 'object' || value === null) {
return false
}
const result = value as Partial<AgentSessionClaimedSpawnResult>
return (
(result.disposition === 'created' || result.disposition === 'adopted') &&
isAgentSessionOwnerBinding(result.owner) &&
result.owner.phase === 'live'
)
}