mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
fix(pty): gate resumes by relay epoch
This commit is contained in:
@@ -41,16 +41,18 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{
|
||||
daemon: ctx.isDaemonHostSpawn,
|
||||
reattach: ctx.result.isReattach ?? false
|
||||
})
|
||||
recordCodexPaneAccountForSpawn({
|
||||
ptyId: ctx.result.id,
|
||||
isDaemonHostSpawn: ctx.isDaemonHostSpawn,
|
||||
isReattach: ctx.result.isReattach === true,
|
||||
pinnedByResume: ctx.codexResumeHomeSelected,
|
||||
launchCodexHomePath: ctx.selectedCodexHomePath,
|
||||
launchEnv: ctx.baseEnv,
|
||||
target: ctx.codexSelectionTarget,
|
||||
settings: ctx.deps.getSettings?.()
|
||||
})
|
||||
if (!ctx.result.agentResumeUnavailable) {
|
||||
recordCodexPaneAccountForSpawn({
|
||||
ptyId: ctx.result.id,
|
||||
isDaemonHostSpawn: ctx.isDaemonHostSpawn,
|
||||
isReattach: ctx.result.isReattach === true,
|
||||
pinnedByResume: ctx.codexResumeHomeSelected,
|
||||
launchCodexHomePath: ctx.selectedCodexHomePath,
|
||||
launchEnv: ctx.baseEnv,
|
||||
target: ctx.codexSelectionTarget,
|
||||
settings: ctx.deps.getSettings?.()
|
||||
})
|
||||
}
|
||||
ptyOwnership.set(ctx.result.id, args.connectionId ?? null)
|
||||
if (ctx.result.incarnationId) {
|
||||
ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId)
|
||||
|
||||
@@ -27,6 +27,7 @@ import { persistPtyIpcSpawnCommit } from './spawn-commit-persist'
|
||||
|
||||
export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawnResult> {
|
||||
const args = ctx.args
|
||||
const agentResumeDeclined = ctx.result.agentResumeUnavailable === true
|
||||
const { rendererPreSignaled, rendererAlreadyRegistered } = await persistPtyIpcSpawnCommit(ctx)
|
||||
|
||||
// Why: seed the headless emulator before registerPty so concurrent live PTY data lands on top of the seed, not replacing it (mobile keeps the daemon-restored scrollback).
|
||||
@@ -76,15 +77,17 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
|
||||
args.worktreeId.length > 0 &&
|
||||
args.worktreeId.length <= 512
|
||||
) {
|
||||
const agentLaunchAuthority = admitRendererAgentLaunchAuthority({
|
||||
launchToken: args.launchToken,
|
||||
spawnEnv: ctx.spawnEnv,
|
||||
launchAgent: args.launchAgent,
|
||||
launchConfig: ctx.effectiveLaunchConfig,
|
||||
isReattach: ctx.result.isReattach === true,
|
||||
hasStablePaneOwner: ctx.stablePaneOwner !== null,
|
||||
incarnationId: ctx.result.incarnationId
|
||||
})
|
||||
const agentLaunchAuthority = agentResumeDeclined
|
||||
? null
|
||||
: admitRendererAgentLaunchAuthority({
|
||||
launchToken: args.launchToken,
|
||||
spawnEnv: ctx.spawnEnv,
|
||||
launchAgent: args.launchAgent,
|
||||
launchConfig: ctx.effectiveLaunchConfig,
|
||||
isReattach: ctx.result.isReattach === true,
|
||||
hasStablePaneOwner: ctx.stablePaneOwner !== null,
|
||||
incarnationId: ctx.result.incarnationId
|
||||
})
|
||||
const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity({
|
||||
isReattach: ctx.result.isReattach === true,
|
||||
launchAgent: ctx.result.launchAgent,
|
||||
@@ -125,13 +128,13 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
|
||||
// seed but the list/read records still live main-side.
|
||||
seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result)
|
||||
// Why: arm main's per-PTY Command Code output detector from the launch command (startupCommand parity); banner detection covers PTYs without one.
|
||||
if (!ctx.stablePaneOwner) {
|
||||
if (!ctx.stablePaneOwner && !agentResumeDeclined) {
|
||||
ctx.deps.runtime?.noteTerminalSpawnCommand?.(
|
||||
ctx.result.id,
|
||||
typeof ctx.launchCommand === 'string' ? ctx.launchCommand : null
|
||||
)
|
||||
}
|
||||
if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) {
|
||||
if (ctx.isClaudeLaunch && !ctx.stablePaneOwner && !agentResumeDeclined) {
|
||||
markClaudePtySpawned(ctx.result.id)
|
||||
}
|
||||
// Why: record the paneKey mapping so clearProviderPtyState can clear the agent-hooks server's per-paneKey caches on exit.
|
||||
@@ -139,7 +142,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
|
||||
const rememberedPaneKey = ctx.validatedPaneKey
|
||||
? rememberPaneKeyForPty(ctx.result.id, ctx.validatedPaneKey)
|
||||
: null
|
||||
if (ctx.legacySpawnPaneKey && ctx.migrationUnsupportedPaneKey) {
|
||||
if (!agentResumeDeclined && ctx.legacySpawnPaneKey && ctx.migrationUnsupportedPaneKey) {
|
||||
agentHookServer.registerPaneKeyAlias(
|
||||
ctx.legacySpawnPaneKey.paneKey,
|
||||
ctx.migrationUnsupportedPaneKey,
|
||||
@@ -157,7 +160,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
|
||||
Date.now(),
|
||||
{ authorityVerified: true }
|
||||
)
|
||||
} else if (ctx.validatedPaneKey) {
|
||||
} else if (!agentResumeDeclined && ctx.validatedPaneKey) {
|
||||
if (!ctx.result.isReattach) {
|
||||
clearMigrationUnsupportedPtysForPaneKey(ctx.validatedPaneKey)
|
||||
}
|
||||
@@ -189,7 +192,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
|
||||
})
|
||||
}
|
||||
// Why: telemetry-plan.md§Agent launch semantics — fire agent_started only after spawn resolved; safeParse each field so a spoofed IPC payload can't poison the event (missing required field skips it).
|
||||
if (args.telemetry && !ctx.stablePaneOwner) {
|
||||
if (args.telemetry && !ctx.stablePaneOwner && !agentResumeDeclined) {
|
||||
const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind)
|
||||
const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source)
|
||||
const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind)
|
||||
@@ -211,7 +214,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
|
||||
ctx.snapshotKittyFlagsCoverReconciledSeq
|
||||
? { snapshotSeq: ctx.reconciledSnapshotSeq }
|
||||
: { snapshotKittyKeyboardFlags: undefined }),
|
||||
...(!ctx.result.isReattach && ctx.effectiveLaunchConfig
|
||||
...(!ctx.result.isReattach && !agentResumeDeclined && ctx.effectiveLaunchConfig
|
||||
? { launchConfig: ctx.effectiveLaunchConfig }
|
||||
: {}),
|
||||
// Why: a daemon-retry race can surface isReattach even for a minted session id, and a reattach must never claim its cwd was remapped.
|
||||
@@ -226,7 +229,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
|
||||
}
|
||||
// Why: renderer tab state cannot reliably infer background and reattached PTYs in the daemon inventory.
|
||||
ctx.deps.sendPtySpawnedToRenderer(ctx.result.id)
|
||||
if (!args.connectionId) {
|
||||
if (!args.connectionId && !agentResumeDeclined) {
|
||||
ctx.deps.options?.onCodexHomePtySpawned?.({
|
||||
id: ctx.result.id,
|
||||
codexHomePath: ctx.selectedCodexHomePath,
|
||||
|
||||
@@ -77,6 +77,9 @@ export async function buildPtyIpcSpawnOptions(
|
||||
if (isTuiAgent(args.launchAgent)) {
|
||||
ctx.spawnOptions.launchAgent = args.launchAgent
|
||||
}
|
||||
if (args.resumeProviderSession !== undefined) {
|
||||
ctx.spawnOptions.resumeProviderSession = args.resumeProviderSession
|
||||
}
|
||||
if (args.worktreeId !== undefined) {
|
||||
ctx.spawnOptions.worktreeId = args.worktreeId
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { IPtyProvider, PtySpawnOptions } from '../../../providers/types'
|
||||
import { parseAppSshPtyId } from '../../../providers/ssh-pty-id'
|
||||
|
||||
const RELAY_STATUS_TIMEOUT_MS = 2_000
|
||||
const relayMintEpochByProvider = new WeakMap<IPtyProvider, Promise<string | undefined>>()
|
||||
|
||||
function parseRelayPtyMintEpoch(relayPtyId: string): string | undefined {
|
||||
const match = /^pty2:([^:]+):(\d+)$/.exec(relayPtyId)
|
||||
if (!match || !Number.isSafeInteger(Number(match[2]))) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const epoch = decodeURIComponent(match[1])
|
||||
return epoch.length > 0 ? epoch : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function readRelayMintEpoch(provider: IPtyProvider): Promise<string | undefined> {
|
||||
const cached = relayMintEpochByProvider.get(provider)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const read = provider
|
||||
.requestHostRpc?.('relay.status', {}, { timeoutMs: RELAY_STATUS_TIMEOUT_MS })
|
||||
.then((status) => {
|
||||
if (!status || typeof status !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
const epoch = (status as { ptyIdMintEpoch?: unknown }).ptyIdMintEpoch
|
||||
return typeof epoch === 'string' && epoch.length > 0 ? epoch : undefined
|
||||
})
|
||||
.catch(() => undefined)
|
||||
const result = read ?? Promise.resolve(undefined)
|
||||
relayMintEpochByProvider.set(provider, result)
|
||||
return result
|
||||
}
|
||||
|
||||
export async function compareStablePaneRelayEpoch(args: {
|
||||
provider: IPtyProvider
|
||||
ownerPtyId: string
|
||||
connectionId: string | null | undefined
|
||||
}): Promise<'same' | 'different' | 'unknown'> {
|
||||
if (!args.connectionId) {
|
||||
return 'unknown'
|
||||
}
|
||||
const relayPtyId = parseAppSshPtyId(args.ownerPtyId)?.relayPtyId
|
||||
const ownerEpoch = relayPtyId ? parseRelayPtyMintEpoch(relayPtyId) : undefined
|
||||
if (!ownerEpoch || !args.provider.requestHostRpc) {
|
||||
return 'unknown'
|
||||
}
|
||||
const currentEpoch = await readRelayMintEpoch(args.provider)
|
||||
if (!currentEpoch) {
|
||||
return 'unknown'
|
||||
}
|
||||
return currentEpoch === ownerEpoch ? 'same' : 'different'
|
||||
}
|
||||
|
||||
function stripAgentResumeOptions(options: PtySpawnOptions): PtySpawnOptions {
|
||||
const stripped = { ...options }
|
||||
delete stripped.launchAgent
|
||||
delete stripped.command
|
||||
delete stripped.commandDelivery
|
||||
delete stripped.startupCommandDelivery
|
||||
delete stripped.resumeProviderSession
|
||||
delete stripped.startupIngress
|
||||
delete stripped.agentSessionEnsure
|
||||
delete stripped.agentSessionCreateOperationId
|
||||
if (stripped.env) {
|
||||
stripped.env = { ...stripped.env }
|
||||
delete stripped.env.ORCA_AGENT_LAUNCH_TOKEN
|
||||
}
|
||||
stripped.envToDelete = [...new Set([...(stripped.envToDelete ?? []), 'ORCA_AGENT_LAUNCH_TOKEN'])]
|
||||
return stripped
|
||||
}
|
||||
|
||||
export async function deriveStablePaneFreshSpawnOptions(args: {
|
||||
provider: IPtyProvider
|
||||
ownerPtyId: string
|
||||
connectionId: string | null | undefined
|
||||
spawnOptions: PtySpawnOptions
|
||||
}): Promise<{ options: PtySpawnOptions; agentResumeDeclined: boolean }> {
|
||||
const verdict = await compareStablePaneRelayEpoch(args)
|
||||
const hasAgentResumeIntent = Boolean(
|
||||
args.spawnOptions.launchAgent ||
|
||||
args.spawnOptions.resumeProviderSession ||
|
||||
args.spawnOptions.agentSessionEnsure
|
||||
)
|
||||
// Compatibility: legacy pty-N ids, old relays, RPC failure/timeout, and non-SSH panes are no
|
||||
// verdict and resume as today. We grandfather them because ordinary absence means the same
|
||||
// relay lost its PTY; residual risk requires a legacy id, relay replacement, and surviving orphan.
|
||||
const agentResumeDeclined = verdict === 'different' && hasAgentResumeIntent
|
||||
return {
|
||||
options: agentResumeDeclined ? stripAgentResumeOptions(args.spawnOptions) : args.spawnOptions,
|
||||
agentResumeDeclined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../../../providers/types'
|
||||
import { toAppSshPtyId } from '../../../providers/ssh-pty-id'
|
||||
import { spawnForStablePane, type StablePaneOwner } from './stable-owner'
|
||||
|
||||
type EpochAwareSpawnOptions = PtySpawnOptions & { resumeProviderSession?: unknown }
|
||||
type EpochAwareSpawnResult = PtySpawnResult & { agentResumeUnavailable?: true }
|
||||
|
||||
const owner = (relayPtyId: string): StablePaneOwner => ({
|
||||
tabId: 'tab-epoch-gate',
|
||||
leafId: '11111111-1111-4111-8111-111111111111',
|
||||
ptyId: toAppSshPtyId('remote', relayPtyId)
|
||||
})
|
||||
|
||||
const agentSpawnOptions = (): EpochAwareSpawnOptions => ({
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
cwd: '/workspace',
|
||||
env: { KEEP: 'yes', ORCA_AGENT_LAUNCH_TOKEN: 'launch-token' },
|
||||
envToDelete: ['DELETE_ME'],
|
||||
command: 'codex resume session',
|
||||
commandDelivery: 'provider',
|
||||
startupCommandDelivery: 'shell-ready',
|
||||
launchAgent: 'codex',
|
||||
resumeProviderSession: { key: 'session_id', id: 'session' },
|
||||
startupIngress: { colors: { foreground: '#ffffff' }, deadlineMs: 5_000 },
|
||||
agentSessionEnsure: {} as never,
|
||||
agentSessionCreateOperationId: 'create-operation'
|
||||
})
|
||||
|
||||
function createProvider(status: unknown | Error) {
|
||||
const spawns: EpochAwareSpawnOptions[] = []
|
||||
const requestHostRpc = vi.fn(async () => {
|
||||
if (status instanceof Error) {
|
||||
throw status
|
||||
}
|
||||
return status
|
||||
})
|
||||
const provider = {
|
||||
requestHostRpc,
|
||||
spawn: vi.fn(async (options: EpochAwareSpawnOptions) => {
|
||||
spawns.push(options)
|
||||
if (options.attachOnly) {
|
||||
throw new Error(`PTY "${options.sessionId}" not found`)
|
||||
}
|
||||
return { id: toAppSshPtyId('remote', 'pty2:current:2') }
|
||||
})
|
||||
} as unknown as IPtyProvider
|
||||
return { provider, requestHostRpc, spawns }
|
||||
}
|
||||
|
||||
async function runSpawn(
|
||||
relayPtyId: string,
|
||||
status: unknown | Error,
|
||||
spawnOptions: EpochAwareSpawnOptions = agentSpawnOptions()
|
||||
) {
|
||||
const harness = createProvider(status)
|
||||
let freshResult: EpochAwareSpawnResult | undefined
|
||||
const spawned = await spawnForStablePane({
|
||||
runtime: undefined,
|
||||
provider: harness.provider,
|
||||
spawnOptions,
|
||||
owner: owner(relayPtyId),
|
||||
connectionId: 'remote',
|
||||
onFreshSpawn: (result) => {
|
||||
freshResult = result as EpochAwareSpawnResult
|
||||
}
|
||||
})
|
||||
return {
|
||||
...harness,
|
||||
freshOptions: harness.spawns[1],
|
||||
result: spawned.result as EpochAwareSpawnResult,
|
||||
freshResult
|
||||
}
|
||||
}
|
||||
|
||||
describe('spawnForStablePane relay epoch gate', () => {
|
||||
it('declines an agent resume owned by a different relay epoch', async () => {
|
||||
const { freshOptions, result, freshResult, requestHostRpc } = await runSpawn(
|
||||
'pty2:previous:1',
|
||||
{ ptyIdMintEpoch: 'current' }
|
||||
)
|
||||
|
||||
expect(requestHostRpc).toHaveBeenCalledWith(
|
||||
'relay.status',
|
||||
{},
|
||||
expect.objectContaining({ timeoutMs: expect.any(Number) })
|
||||
)
|
||||
expect(freshOptions).toMatchObject({
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
cwd: '/workspace',
|
||||
env: { KEEP: 'yes' },
|
||||
envToDelete: expect.arrayContaining(['DELETE_ME', 'ORCA_AGENT_LAUNCH_TOKEN'])
|
||||
})
|
||||
expect(freshOptions).not.toHaveProperty('launchAgent')
|
||||
expect(freshOptions).not.toHaveProperty('command')
|
||||
expect(freshOptions).not.toHaveProperty('commandDelivery')
|
||||
expect(freshOptions).not.toHaveProperty('startupCommandDelivery')
|
||||
expect(freshOptions).not.toHaveProperty('resumeProviderSession')
|
||||
expect(freshOptions).not.toHaveProperty('startupIngress')
|
||||
expect(freshOptions).not.toHaveProperty('agentSessionEnsure')
|
||||
expect(freshOptions).not.toHaveProperty('agentSessionCreateOperationId')
|
||||
expect(result.agentResumeUnavailable).toBe(true)
|
||||
expect(freshResult?.agentResumeUnavailable).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves an agent resume owned by the current relay epoch', async () => {
|
||||
const spawnOptions = agentSpawnOptions()
|
||||
const { freshOptions, result } = await runSpawn('pty2:current:1', {
|
||||
ptyIdMintEpoch: 'current'
|
||||
})
|
||||
|
||||
expect(freshOptions).toEqual(spawnOptions)
|
||||
expect(result.agentResumeUnavailable).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['legacy owner id', 'pty-1', { ptyIdMintEpoch: 'current' }],
|
||||
['unknown relay epoch', 'pty2:previous:1', {}],
|
||||
['relay status failure', 'pty2:previous:1', new Error('relay unavailable')]
|
||||
])('preserves current behavior for %s', async (_label, relayPtyId, status) => {
|
||||
const spawnOptions = agentSpawnOptions()
|
||||
const { freshOptions, result } = await runSpawn(relayPtyId, status)
|
||||
|
||||
expect(freshOptions).toEqual(spawnOptions)
|
||||
expect(result.agentResumeUnavailable).toBeUndefined()
|
||||
})
|
||||
|
||||
it('decodes the epoch embedded in an app-facing SSH PTY id', async () => {
|
||||
const spawnOptions = agentSpawnOptions()
|
||||
const { freshOptions } = await runSpawn('pty2:relay%3Aepoch:1', {
|
||||
ptyIdMintEpoch: 'relay:epoch'
|
||||
})
|
||||
|
||||
expect(freshOptions).toEqual(spawnOptions)
|
||||
})
|
||||
|
||||
it('reads relay status only once per provider connection generation', async () => {
|
||||
const harness = createProvider({ ptyIdMintEpoch: 'current' })
|
||||
const spawn = () =>
|
||||
spawnForStablePane({
|
||||
runtime: undefined,
|
||||
provider: harness.provider,
|
||||
spawnOptions: agentSpawnOptions(),
|
||||
owner: owner('pty2:current:1'),
|
||||
connectionId: 'remote'
|
||||
})
|
||||
|
||||
await Promise.all([spawn(), spawn()])
|
||||
expect(harness.requestHostRpc).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not label a plain replacement shell as an unavailable agent resume', async () => {
|
||||
const { freshOptions, result } = await runSpawn(
|
||||
'pty2:previous:1',
|
||||
{ ptyIdMintEpoch: 'current' },
|
||||
{ cols: 80, rows: 24 }
|
||||
)
|
||||
|
||||
expect(freshOptions).toEqual({ cols: 80, rows: 24 })
|
||||
expect(result.agentResumeUnavailable).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state'
|
||||
import { isPtyAlreadyGoneError } from '../provider/liveness'
|
||||
import { clearProviderPtyState } from '../provider/state-cleanup'
|
||||
import { deriveStablePaneFreshSpawnOptions } from './relay-pty-mint-epoch'
|
||||
|
||||
export type StablePaneOwner = {
|
||||
handle?: string
|
||||
@@ -287,7 +288,18 @@ export async function spawnForStablePane(
|
||||
return attached
|
||||
}
|
||||
}
|
||||
const result = await args.provider.spawn(args.spawnOptions)
|
||||
const freshSpawn = args.owner
|
||||
? await deriveStablePaneFreshSpawnOptions({
|
||||
provider: args.provider,
|
||||
ownerPtyId: args.owner.ptyId,
|
||||
connectionId: args.connectionId,
|
||||
spawnOptions: args.spawnOptions
|
||||
})
|
||||
: { options: args.spawnOptions, agentResumeDeclined: false }
|
||||
const providerResult = await args.provider.spawn(freshSpawn.options)
|
||||
const result = freshSpawn.agentResumeDeclined
|
||||
? { ...providerResult, agentResumeUnavailable: true as const }
|
||||
: providerResult
|
||||
args.onFreshSpawn?.(result)
|
||||
return { result, owner: null }
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import type { RuntimePtySpawnState } from './spawn-state'
|
||||
|
||||
export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
|
||||
const args = ctx.args
|
||||
const agentResumeDeclined = ctx.result.agentResumeUnavailable === true
|
||||
const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result)
|
||||
try {
|
||||
ctx.stablePaneBindingPersisted = persistAdmittedStablePaneBinding({
|
||||
@@ -138,16 +139,18 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
|
||||
if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) {
|
||||
ptySizes.delete(ctx.effectiveSessionAppId)
|
||||
}
|
||||
recordCodexPaneAccountForSpawn({
|
||||
ptyId: ctx.result.id,
|
||||
isDaemonHostSpawn: ctx.isDaemonHostSpawn,
|
||||
isReattach: ctx.result.isReattach === true,
|
||||
pinnedByResume: ctx.codexResumeHomeSelected,
|
||||
launchCodexHomePath: ctx.selectedCodexHomePath,
|
||||
launchEnv: args.env,
|
||||
target: ctx.codexSelectionTarget,
|
||||
settings: ctx.deps.getSettings?.()
|
||||
})
|
||||
if (!agentResumeDeclined) {
|
||||
recordCodexPaneAccountForSpawn({
|
||||
ptyId: ctx.result.id,
|
||||
isDaemonHostSpawn: ctx.isDaemonHostSpawn,
|
||||
isReattach: ctx.result.isReattach === true,
|
||||
pinnedByResume: ctx.codexResumeHomeSelected,
|
||||
launchCodexHomePath: ctx.selectedCodexHomePath,
|
||||
launchEnv: args.env,
|
||||
target: ctx.codexSelectionTarget,
|
||||
settings: ctx.deps.getSettings?.()
|
||||
})
|
||||
}
|
||||
if (ctx.hostSessionBinding && !ctx.stablePaneBindingPersisted) {
|
||||
try {
|
||||
const binding = {
|
||||
@@ -223,13 +226,13 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
|
||||
// Why: runtime-controller creates (headless serve, CLI, splits) adopt surviving daemon sessions too; without this seed their records stay blank.
|
||||
seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result)
|
||||
// Why: arms main's per-PTY Command Code output detector from the launch command (renderer startupCommand parity).
|
||||
if (!ctx.stablePaneOwner) {
|
||||
if (!ctx.stablePaneOwner && !agentResumeDeclined) {
|
||||
ctx.deps.runtime?.noteTerminalSpawnCommand?.(ctx.result.id, ctx.launchCommand ?? null)
|
||||
}
|
||||
if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) {
|
||||
if (ctx.isClaudeLaunch && !ctx.stablePaneOwner && !agentResumeDeclined) {
|
||||
markClaudePtySpawned(ctx.result.id)
|
||||
}
|
||||
if (args.telemetry && !ctx.stablePaneOwner) {
|
||||
if (args.telemetry && !ctx.stablePaneOwner && !agentResumeDeclined) {
|
||||
const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind)
|
||||
const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source)
|
||||
const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind)
|
||||
@@ -267,7 +270,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
|
||||
}
|
||||
// Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal.
|
||||
ctx.deps.sendPtySpawnedToRenderer(ctx.result.id)
|
||||
if (!args.connectionId) {
|
||||
if (!args.connectionId && !agentResumeDeclined) {
|
||||
ctx.deps.options?.onCodexHomePtySpawned?.({
|
||||
id: ctx.result.id,
|
||||
codexHomePath: ctx.selectedCodexHomePath,
|
||||
@@ -286,8 +289,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
|
||||
})
|
||||
}
|
||||
const response = {
|
||||
id: ctx.result.id,
|
||||
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
|
||||
...ctx.result,
|
||||
...(ctx.stablePaneOwner && (ctx.stablePaneOwner.handle || args.preAllocatedHandle)
|
||||
? {
|
||||
stablePaneOwner: {
|
||||
@@ -296,8 +298,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
|
||||
leafId: ctx.stablePaneOwner.leafId
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(ctx.result.agentSessionEnsure ? { agentSessionEnsure: ctx.result.agentSessionEnsure } : {})
|
||||
: {})
|
||||
}
|
||||
resolvePaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, {
|
||||
...ctx.result,
|
||||
|
||||
@@ -101,6 +101,9 @@ export async function buildRuntimePtySpawnOptions(
|
||||
if (isTuiAgent(args.launchAgent)) {
|
||||
ctx.spawnOptions.launchAgent = args.launchAgent
|
||||
}
|
||||
if (args.resumeProviderSession !== undefined) {
|
||||
ctx.spawnOptions.resumeProviderSession = args.resumeProviderSession
|
||||
}
|
||||
if (args.worktreeId !== undefined) {
|
||||
ctx.spawnOptions.worktreeId = args.worktreeId
|
||||
}
|
||||
|
||||
@@ -23,13 +23,15 @@ function toRuntimeSpawnReply(result: {
|
||||
wslDistro?: string | null
|
||||
stablePaneOwner?: { handle: string; tabId: string; leafId: string }
|
||||
agentSessionEnsure?: AgentSessionClaimedSpawnResult
|
||||
agentResumeUnavailable?: true
|
||||
}) {
|
||||
return {
|
||||
id: result.id,
|
||||
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
|
||||
...(typeof result.wslDistro === 'string' ? { wslDistro: result.wslDistro } : {}),
|
||||
...(result.stablePaneOwner ? { stablePaneOwner: result.stablePaneOwner } : {}),
|
||||
...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {})
|
||||
...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}),
|
||||
...(result.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
import type { PtyProcessInfo } from './pty-process-info'
|
||||
import type { TerminalExitCause } from '../../shared/terminal-exit-cause'
|
||||
import type { TerminalOwner } from '../../shared/terminal-owner'
|
||||
import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume'
|
||||
|
||||
export type {
|
||||
PtyBackgroundStreamEvent,
|
||||
@@ -57,6 +58,8 @@ export type PtySpawnOptions = {
|
||||
startupCommandDelivery?: StartupCommandDelivery
|
||||
/** Minimal allowlisted launch ownership preserved by daemon reattach. */
|
||||
launchAgent?: TuiAgent
|
||||
/** Main-only resume intent retained until stable-pane recovery chooses the answering relay. */
|
||||
resumeProviderSession?: AgentProviderSessionMetadata
|
||||
/** Orca worktree identity. When present, the local provider scopes shell
|
||||
* history to this worktree so ArrowUp only surfaces local commands. */
|
||||
worktreeId?: string
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { TerminalOwner } from '../../shared/terminal-owner'
|
||||
|
||||
export type PtySpawnResult = {
|
||||
agentSessionEnsure?: AgentSessionClaimedSpawnResult
|
||||
/** Main declined an agent resume owned by a different relay incarnation. */
|
||||
agentResumeUnavailable?: true
|
||||
/** App-facing PTY id. Remote providers must return globally routable ids,
|
||||
* not relay-local handles, because renderer/runtime IPC routes by this key. */
|
||||
id: string
|
||||
|
||||
@@ -203,6 +203,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
|
||||
if (result.wslDistro) {
|
||||
this.preparePtyExecutionContext(result.id, result.wslDistro)
|
||||
}
|
||||
const agentResumeDeclined = result.agentResumeUnavailable === true
|
||||
this.registerPty(result.id, workspace.id, workspace.connectionId, {
|
||||
tabId,
|
||||
leafId,
|
||||
@@ -228,12 +229,13 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
|
||||
pty.title = null
|
||||
pty.titleUpdatedAt = null
|
||||
}
|
||||
pty.launchConfig = effectiveLaunchConfig
|
||||
? dependencies.copySleepingAgentLaunchConfig(effectiveLaunchConfig)
|
||||
: null
|
||||
pty.launchToken = launchToken ?? null
|
||||
pty.launchIncarnationId = launchToken ? pty.incarnationId : null
|
||||
pty.launchAgent = launchOpts.launchAgent ?? null
|
||||
pty.launchConfig =
|
||||
!agentResumeDeclined && effectiveLaunchConfig
|
||||
? dependencies.copySleepingAgentLaunchConfig(effectiveLaunchConfig)
|
||||
: null
|
||||
pty.launchToken = agentResumeDeclined ? null : (launchToken ?? null)
|
||||
pty.launchIncarnationId = !agentResumeDeclined && launchToken ? pty.incarnationId : null
|
||||
pty.launchAgent = agentResumeDeclined ? null : (launchOpts.launchAgent ?? null)
|
||||
}
|
||||
pty.tabId = tabId
|
||||
pty.paneKey = paneKey
|
||||
@@ -258,9 +260,13 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
|
||||
ptyId: result.id,
|
||||
title: launchOpts.title ?? null,
|
||||
...(cwd !== workspace.path ? { cwd } : {}),
|
||||
...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}),
|
||||
...(launchToken ? { launchToken } : {}),
|
||||
...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}),
|
||||
...(!agentResumeDeclined && effectiveLaunchConfig
|
||||
? { launchConfig: effectiveLaunchConfig }
|
||||
: {}),
|
||||
...(!agentResumeDeclined && launchToken ? { launchToken } : {}),
|
||||
...(!agentResumeDeclined && launchOpts.launchAgent
|
||||
? { launchAgent: launchOpts.launchAgent }
|
||||
: {}),
|
||||
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
|
||||
activate: presentation === 'focused',
|
||||
...(presentation ? { presentation } : {}),
|
||||
@@ -289,6 +295,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
|
||||
...(result.agentSessionEnsure
|
||||
? { agentSessionDisposition: result.agentSessionEnsure.disposition }
|
||||
: {}),
|
||||
...(agentResumeDeclined ? { agentResumeUnavailable: true as const } : {}),
|
||||
...(adoptedStablePane ? { isReattach: true as const } : {}),
|
||||
...(warning ? { warning } : {})
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ export type RuntimePtyController = {
|
||||
wslDistro?: string
|
||||
stablePaneOwner?: { handle: string; tabId: string; leafId: string }
|
||||
agentSessionEnsure?: AgentSessionClaimedSpawnResult
|
||||
agentResumeUnavailable?: true
|
||||
}>
|
||||
write(ptyId: string, data: string): boolean
|
||||
writeAgentSessionProof?(
|
||||
|
||||
@@ -501,6 +501,10 @@ export class PtyHandler {
|
||||
this.dispatcher.onLegacyPtyCapacity?.(() => this.handleLegacyCapacity()) ?? null
|
||||
}
|
||||
|
||||
get mintEpoch(): string {
|
||||
return this.ptyIdMintEpoch
|
||||
}
|
||||
|
||||
setConsumerDeliveryPaused(id: string, paused: boolean): void {
|
||||
if (paused) {
|
||||
this.consumerPausedOutputPtys.add(id)
|
||||
|
||||
@@ -138,6 +138,7 @@ function registerRelayStatus(
|
||||
): void {
|
||||
primaryChannel.dispatcher.onRequest('relay.status', async () => ({
|
||||
capabilities: SKILL_RELAY_CAPABILITIES,
|
||||
ptyIdMintEpoch: runtime.ptyHandler.mintEpoch,
|
||||
pid: process.pid,
|
||||
uptimeMs: Date.now() - startedAt,
|
||||
detached: options.detached,
|
||||
|
||||
@@ -770,11 +770,13 @@ describe('Subprocess: Relay entry point', () => {
|
||||
expect(resp.error).toBeUndefined()
|
||||
const status = resp.result as {
|
||||
pid: number
|
||||
ptyIdMintEpoch?: string
|
||||
memory: { rss: number }
|
||||
ptys: { active: number }
|
||||
socket: { owned: boolean; listening: boolean; clients: number }
|
||||
}
|
||||
expect(status.pid).toBeGreaterThan(0)
|
||||
expect(status.ptyIdMintEpoch).toEqual(expect.any(String))
|
||||
expect(status.memory.rss).toBeGreaterThan(0)
|
||||
expect(status.ptys.active).toBe(0)
|
||||
expect(status.socket).toMatchObject({ owned: true, listening: true, clients: 0 })
|
||||
|
||||
+3
@@ -457,6 +457,9 @@ describe('connectPanePty', () => {
|
||||
|
||||
expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1)
|
||||
expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(1, 'resume-unavailable')
|
||||
expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey)
|
||||
expect(mockStoreState.clearPaneForegroundAgent).toHaveBeenCalledWith(paneKey)
|
||||
expect(transport.sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resumes from an unambiguous legacy sleeping record when cold-restoring a preserved pane', async () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { resolveSshReconnectModelPaint } from './resolve-ssh-reconnect-model-pai
|
||||
|
||||
import type { ReattachPayloadContext } from './reattach-payload-context'
|
||||
import type { ReattachPayloadSession } from './reattach-payload-session'
|
||||
import { cancelDeclinedAgentResume } from './declined-agent-resume'
|
||||
|
||||
export function createReattachPayloadHandlers(
|
||||
session: ReattachPayloadSession,
|
||||
@@ -279,6 +280,7 @@ export function createReattachPayloadHandlers(
|
||||
const didPrepareResume = session.applyColdRestoreAgentResumeStartup(preparedStartup)
|
||||
if (didPrepareResume) {
|
||||
if (ctx.connectResult.agentResumeUnavailable) {
|
||||
cancelDeclinedAgentResume(session)
|
||||
// Why: main dropped the resume argv, so this pane is a NEW session —
|
||||
// the plain restored banner would claim the old one came back.
|
||||
session.showSessionRestoredBanner('resume-unavailable')
|
||||
@@ -297,7 +299,11 @@ export function createReattachPayloadHandlers(
|
||||
if (!isRemoteRuntimePtyId(ctx.ptyId)) {
|
||||
window.api.pty.ackColdRestore(ctx.ptyId)
|
||||
}
|
||||
if (didPrepareResume && !ctx.coldRestoreStartup) {
|
||||
if (
|
||||
didPrepareResume &&
|
||||
!ctx.coldRestoreStartup &&
|
||||
!ctx.connectResult.agentResumeUnavailable
|
||||
) {
|
||||
session.schedulePendingStartupCommandDelivery()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ export type ConnectPanePtySession = {
|
||||
pane: ManagedPane
|
||||
manager: PaneManager
|
||||
deps: PtyConnectionDeps
|
||||
cacheKey: string
|
||||
pendingStartupCommand: { command: string } | null
|
||||
startupInjectTimer: ReturnType<typeof setTimeout> | null
|
||||
cancelStartupDraftPasteDelivery: () => void
|
||||
clearRegisteredStartupLaunchConfig: () => void
|
||||
// oxlint-disable-next-line typescript/no-explicit-any -- session bag for mechanical extract
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ export function connectPanePty(
|
||||
session.cleanupHiddenOutputRestoreFloodRepaint = (): void => {}
|
||||
session.resetRendererOrderedSeqForPtyExit = () => {}
|
||||
session.cleanupStartupDraftPasteTimers = (): void => {}
|
||||
session.cancelStartupDraftPasteDelivery = (): void => {}
|
||||
session.unregisterE2ePtyDataInjection = (): void => {}
|
||||
session.startupInjectTimer = null
|
||||
session.agentTaskCompleteNotificationGraceTimer = null
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
type DeclinedAgentResumeSession = Pick<
|
||||
ConnectPanePtySession,
|
||||
| 'cacheKey'
|
||||
| 'cancelStartupDraftPasteDelivery'
|
||||
| 'clearRegisteredStartupLaunchConfig'
|
||||
| 'pendingStartupCommand'
|
||||
| 'startupInjectTimer'
|
||||
>
|
||||
|
||||
export function cancelDeclinedAgentResume(session: DeclinedAgentResumeSession): void {
|
||||
session.pendingStartupCommand = null
|
||||
if (session.startupInjectTimer !== null) {
|
||||
clearTimeout(session.startupInjectTimer)
|
||||
session.startupInjectTimer = null
|
||||
}
|
||||
session.cancelStartupDraftPasteDelivery()
|
||||
session.clearRegisteredStartupLaunchConfig()
|
||||
useAppStore.getState().clearPaneForegroundAgent(session.cacheKey)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
import { resolveTerminalTabId } from './terminal-tab-id'
|
||||
import { cancelDeclinedAgentResume } from './declined-agent-resume'
|
||||
|
||||
export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
session.startFreshSpawn = (
|
||||
@@ -209,7 +210,15 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
}
|
||||
return accepted ? resolvedPtyId : null
|
||||
}
|
||||
if (spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId) {
|
||||
const agentResumeUnavailable = Boolean(
|
||||
spawnedPtyId &&
|
||||
typeof spawnedPtyId === 'object' &&
|
||||
'id' in spawnedPtyId &&
|
||||
spawnedPtyId.agentResumeUnavailable
|
||||
)
|
||||
if (agentResumeUnavailable) {
|
||||
cancelDeclinedAgentResume(session)
|
||||
} else if (spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId) {
|
||||
session.registerEffectiveLaunchConfig(spawnedPtyId.launchConfig, {
|
||||
...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}),
|
||||
...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {})
|
||||
@@ -225,11 +234,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
foreground: shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)
|
||||
})
|
||||
}
|
||||
if (
|
||||
spawnedPtyId &&
|
||||
typeof spawnedPtyId === 'object' &&
|
||||
spawnedPtyId.agentResumeUnavailable
|
||||
) {
|
||||
if (spawnedPtyId && typeof spawnedPtyId === 'object' && agentResumeUnavailable) {
|
||||
// Why: main dropped the resume argv, so this pane is a NEW session —
|
||||
// the plain restored banner would claim the old one came back.
|
||||
session.showSessionRestoredBanner('resume-unavailable')
|
||||
@@ -281,7 +286,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
void window.api.pty.settlePaneSerializer(session.cacheKey, gen).catch(() => {})
|
||||
}
|
||||
}
|
||||
if (resolvedPtyId && session.connectionId) {
|
||||
if (resolvedPtyId && session.connectionId && !agentResumeUnavailable) {
|
||||
if (
|
||||
session.shouldUseProviderSshStartupDelivery &&
|
||||
(startupOverride?.command || session.paneStartup?.command)
|
||||
|
||||
@@ -88,6 +88,11 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void {
|
||||
}
|
||||
}
|
||||
session.cleanupStartupDraftPasteTimers = clearStartupDraftPasteTimers
|
||||
session.cancelStartupDraftPasteDelivery = (): void => {
|
||||
startupDraftPasteSettled = true
|
||||
clearStartupDraftPasteTimers()
|
||||
session.releaseUnattemptedStartupDraftPasteDelivery()
|
||||
}
|
||||
const getStartupDraftPtyId = (): string | null => {
|
||||
const ptyId = session.transport.getPtyId()
|
||||
if (
|
||||
|
||||
@@ -5,12 +5,16 @@ export type ReattachPayloadSession = Pick<
|
||||
| 'applyColdRestoreAgentResumeStartup'
|
||||
| 'applySnapshotKittyKeyboardModes'
|
||||
| 'buildColdRestoreAgentResumeStartup'
|
||||
| 'cacheKey'
|
||||
| 'cancelStartupDraftPasteDelivery'
|
||||
| 'clearRegisteredStartupLaunchConfig'
|
||||
| 'clearSleepingRecordAfterColdRestoreSpawn'
|
||||
| 'consumeRestoredViewportBlankingMarker'
|
||||
| 'createReattachGridPush'
|
||||
| 'isPaneOnAlternateScreen'
|
||||
| 'kittyKeyboardModes'
|
||||
| 'pane'
|
||||
| 'pendingStartupCommand'
|
||||
| 'pendingReattachFit'
|
||||
| 'reattachReplayResetSequence'
|
||||
| 'recordRendererOrderedSeq'
|
||||
@@ -21,6 +25,7 @@ export type ReattachPayloadSession = Pick<
|
||||
| 'shouldPreserveAgentReattachModes'
|
||||
| 'showSessionRestoredBanner'
|
||||
| 'suppressStructuralReplayPtyResize'
|
||||
| 'startupInjectTimer'
|
||||
| 'transport'
|
||||
| 'writeFreshShellViewportBlanking'
|
||||
| 'writeReplayData'
|
||||
|
||||
@@ -2289,7 +2289,10 @@ export function createRemoteRuntimePtyTransport(
|
||||
return {
|
||||
id: remotePtyId,
|
||||
replay: '',
|
||||
...(createdTerminal.isReattach === true ? { isReattach: true } : {})
|
||||
...(createdTerminal.isReattach === true ? { isReattach: true } : {}),
|
||||
...(createdTerminal.agentResumeUnavailable
|
||||
? { agentResumeUnavailable: true as const }
|
||||
: {})
|
||||
} satisfies PtyConnectResult
|
||||
} catch (error) {
|
||||
if (!destroyed && lifecycleEpoch === connectLifecycleEpoch) {
|
||||
|
||||
@@ -260,6 +260,7 @@ export type RuntimeTerminalCreate = {
|
||||
surface?: 'background' | 'visible'
|
||||
warning?: string
|
||||
agentSessionDisposition?: 'created' | 'adopted'
|
||||
agentResumeUnavailable?: true
|
||||
isReattach?: true
|
||||
/** Spawn process identity for host-internal ownership proof. */
|
||||
processId?: number
|
||||
|
||||
Reference in New Issue
Block a user