mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Continue a chat after the terminal agent exits (#33759)
* fix(native-chat): a chat sends again once the terminal's agent exits on macOS The terminal-writer check required the pane's root process to be a shell, but macOS panes spawn under login(1), which stays as the shell's parent, so the check refused every send until the terminal closed. Census the shell login started instead. * Reconcile current terminal writer evidence before continuation * Share fresh foreground confirmation and prove hosted login census * Restore shell process identity import in foreground tracker * Correct process fixture identity and polling callback contracts * Align decisive Windows inspection fixture expectations * Complete foreground inspection Session fixture
This commit is contained in:
@@ -15,6 +15,10 @@ on:
|
||||
- 'src/main/window/main-window-state-lifecycle*'
|
||||
- 'src/main/window/dashboard-popout-window*'
|
||||
- 'src/packages/process-host/**'
|
||||
- 'src/main/runtime/structured-agent-cli-writers*'
|
||||
- 'src/main/providers/macos-tcc-login-shell*'
|
||||
- 'src/shared/process-table-snapshot*'
|
||||
- 'src/shared/darwin-process-table-capture*'
|
||||
- 'src/shared/update-status-types.ts'
|
||||
- 'pnpm-lock.yaml'
|
||||
workflow_dispatch:
|
||||
@@ -29,7 +33,7 @@ concurrency:
|
||||
jobs:
|
||||
updater:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.draft != true
|
||||
runs-on: blacksmith-6vcpu-macos-15
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -55,3 +59,8 @@ jobs:
|
||||
src/main/startup/main-process-quit-update-veto.test.ts
|
||||
src/main/window/main-window-state-lifecycle.test.ts
|
||||
src/main/window/dashboard-popout-window.test.ts
|
||||
- name: Prove login-backed terminal writer census on the disposable runner
|
||||
env:
|
||||
ORCA_MACOS_LOGIN_CENSUS_CI: '1'
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
run: pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/structured-agent-cli-writers.test.ts
|
||||
|
||||
@@ -123,6 +123,28 @@ describe('daemon pty foreground degraded-scan handling', () => {
|
||||
return { proc, handle }
|
||||
}
|
||||
|
||||
it('keeps raw confirmation separate from a known detached writer decision', async () => {
|
||||
resolveAgentForegroundProcessMock
|
||||
.mockResolvedValueOnce({ available: true, processName: 'claude', processId: 999 })
|
||||
.mockResolvedValue({ available: true, processName: 'powershell.exe' })
|
||||
readConptyMock.mockReturnValue(new Set([12345, 999]))
|
||||
const { handle } = await spawnWindowsShell()
|
||||
await readForegroundAt(handle, 0)
|
||||
if (!handle.confirmForegroundProcess) {
|
||||
throw new Error('fixture confirmer missing')
|
||||
}
|
||||
vi.setSystemTime(BASE_TIME_MS + 40_000)
|
||||
resolveAgentForegroundProcessMock.mockClear()
|
||||
expect(await handle.confirmForegroundProcess({ decision: true })).toBe('claude')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(1)
|
||||
expect(await handle.confirmForegroundProcess()).toBe('powershell.exe')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenLastCalledWith(
|
||||
12345,
|
||||
'powershell.exe',
|
||||
expect.not.objectContaining({ anchorProcessId: 999 })
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a cached agent across a degraded (timed-out) scan', async () => {
|
||||
resolveAgentForegroundProcessMock
|
||||
.mockResolvedValueOnce({ available: true, processName: 'claude' })
|
||||
|
||||
@@ -267,7 +267,7 @@ it('uses Windows job membership and the native process table for ordinary childr
|
||||
const handle = createHandle()
|
||||
expect(await handle.confirmForegroundProcess?.()).toBe('vim.exe')
|
||||
expect(await inspect(handle)).toMatchObject({
|
||||
foregroundProcess: 'vim.exe',
|
||||
foregroundProcess: 'zsh',
|
||||
hasChildProcesses: true
|
||||
})
|
||||
expect(readStrict).not.toHaveBeenCalled()
|
||||
@@ -277,8 +277,9 @@ it('keeps missing Windows job membership unverifiable', async () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
resolveWindows.mockResolvedValue({ available: true, processName: null })
|
||||
members.mockReturnValue(null)
|
||||
expect(await createHandle().confirmForegroundProcess?.()).toBeNull()
|
||||
expect(await inspect(createHandle())).toMatchObject({
|
||||
foregroundProcess: null,
|
||||
foregroundProcess: 'zsh',
|
||||
hasChildProcesses: true,
|
||||
childProcessEvidence: 'unverifiable'
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type * as pty from 'node-pty'
|
||||
import { ptyShellProcessId } from '../../windows/windows-pty-job'
|
||||
import * as foreground from '../../providers/pty-foreground-confirmation'
|
||||
import { getAgentForegroundContextPaths } from '../../providers/agent-foreground-context-paths'
|
||||
import { confirmPtyShellForeground } from './pty-shell-foreground-confirmation'
|
||||
import {
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
readWindowsPtyJobProcessIds
|
||||
} from '../../providers/windows-pty-job-membership'
|
||||
|
||||
import { readWindowsConsoleAttachedProcessIds } from '../../providers/windows-console-attached-processes'
|
||||
import {
|
||||
isAgentForegroundWrapperProcess,
|
||||
recognizeAgentProcess,
|
||||
@@ -43,7 +43,7 @@ export type PtyForegroundProcessTracker = {
|
||||
markDead(): void
|
||||
/** rawFallback bypasses cached identity and process-table refreshes for cheap polling. */
|
||||
getForegroundProcess(options?: { rawFallback?: boolean; cachedOnly?: boolean }): string | null
|
||||
confirmForegroundProcess(): Promise<string | null>
|
||||
confirmForegroundProcess(context?: { decision?: boolean }): Promise<string | null>
|
||||
confirmShellForeground(): Promise<boolean>
|
||||
}
|
||||
|
||||
@@ -266,45 +266,44 @@ export function createPtyForegroundProcessTracker(args: {
|
||||
return null
|
||||
}
|
||||
},
|
||||
confirmForegroundProcess: async () => {
|
||||
confirmForegroundProcess: async (context) => {
|
||||
if (args.isDead() || !proc.pid) {
|
||||
return null
|
||||
}
|
||||
const decision = process.platform === 'win32' && context?.decision === true
|
||||
const anchor = cachedAgentForeground
|
||||
try {
|
||||
const fallbackProcess = getFallbackProcess()
|
||||
const fallbackRecognition = recognizeAgentProcess(fallbackProcess)
|
||||
if (
|
||||
!fallbackProcess ||
|
||||
(fallbackRecognition !== null &&
|
||||
process.platform !== 'win32' &&
|
||||
!shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) ||
|
||||
(process.platform !== 'win32' && !shouldInspectFallback(fallbackProcess))
|
||||
) {
|
||||
if (!foreground.shouldConfirmPtyForeground(fallbackProcess, decision)) {
|
||||
return fallbackProcess
|
||||
}
|
||||
const resolution = await resolveForeground(proc.pid, fallbackProcess, {
|
||||
const { resolution, processName: name } = await foreground.confirmFreshPtyForeground({
|
||||
process: proc,
|
||||
fallbackProcess,
|
||||
contextPaths,
|
||||
fresh: true,
|
||||
...(process.platform === 'win32'
|
||||
? {
|
||||
forceProcessScan: true,
|
||||
readWindowsConsoleAttachedProcessIds: () =>
|
||||
readWindowsConsoleAttachedProcessIds(proc.pid)
|
||||
}
|
||||
: {})
|
||||
decision,
|
||||
anchor,
|
||||
anchorAt: anchor?.refreshedAt
|
||||
})
|
||||
if (args.isDead() || !resolution.available) {
|
||||
if (args.isDead()) {
|
||||
return null
|
||||
}
|
||||
const processName =
|
||||
recognizeAgentProcess(resolution.processName)?.processName ?? resolution.processName
|
||||
if (shouldCachePtyForeground(processName, staticName)) {
|
||||
cacheAgentForeground(processName, resolution.processId ?? null)
|
||||
return processName
|
||||
if (!resolution.available && !decision) {
|
||||
return null
|
||||
}
|
||||
cachedAgentForeground = null
|
||||
startupAgentForeground = null
|
||||
return resolution.processName
|
||||
const recognized = recognizeAgentProcess(resolution.processName)
|
||||
const processName = decision ? name : (recognized?.processName ?? resolution.processName)
|
||||
const cacheName = decision ? (recognized?.processName ?? null) : processName
|
||||
if (
|
||||
resolution.available &&
|
||||
shouldCachePtyForeground(cacheName, decision ? false : staticName)
|
||||
) {
|
||||
cacheAgentForeground(cacheName, resolution.processId ?? null)
|
||||
} else if (!decision || processName !== anchor?.processName) {
|
||||
cachedAgentForeground = null
|
||||
startupAgentForeground = null
|
||||
}
|
||||
return processName
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export type SubprocessHandle = {
|
||||
/** cachedOnly must neither query the process nor schedule inspection. */
|
||||
getForegroundProcess(options?: { rawFallback?: boolean; cachedOnly?: boolean }): string | null
|
||||
/** Await process-table evidence captured after this confirmation request. */
|
||||
confirmForegroundProcess?(): Promise<string | null>
|
||||
confirmForegroundProcess?(context?: { decision?: boolean }): Promise<string | null>
|
||||
/** Proves a fresh post-boundary PTY process tree contains only the shell. */
|
||||
confirmShellForeground?(): Promise<boolean>
|
||||
/** Skip stdin command delivery when shell arguments already delivered it. */
|
||||
|
||||
@@ -243,8 +243,10 @@ export class Session {
|
||||
return this.subprocess.getForegroundProcess(options)
|
||||
}
|
||||
|
||||
async confirmForegroundProcess(): Promise<string | null> {
|
||||
return this.subprocess.confirmForegroundProcess?.() ?? this.subprocess.getForegroundProcess()
|
||||
async confirmForegroundProcess(context?: { decision?: boolean }): Promise<string | null> {
|
||||
return (
|
||||
this.subprocess.confirmForegroundProcess?.(context) ?? this.subprocess.getForegroundProcess()
|
||||
)
|
||||
}
|
||||
|
||||
confirmShellForeground(): Promise<boolean> {
|
||||
|
||||
@@ -47,6 +47,7 @@ async function inspect(rawName: string, command: string | null) {
|
||||
pid: 100,
|
||||
incarnationId: 'incarnation-1',
|
||||
isAlive: true,
|
||||
confirmForegroundProcess: async () => rawName,
|
||||
inspectChildProcesses: () => 'unverifiable' as const,
|
||||
getForegroundProcess: () => rawName
|
||||
} as unknown as Session,
|
||||
|
||||
@@ -3,6 +3,35 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SubprocessHandle } from './session-subprocess-handle'
|
||||
import { TerminalHost } from './terminal-host'
|
||||
|
||||
import type { IPty } from 'node-pty'
|
||||
import type { WindowsProcessRow } from '../windows/windows-process-table'
|
||||
import { createPtyForegroundProcessTracker } from './pty-subprocess/foreground-process-tracker'
|
||||
import { inspectSpawnFileWindowsChildProcesses } from './pty-subprocess/spawn-file-child-processes'
|
||||
import { inspectionShowsShellAlone } from '../runtime/run-terminal-shell-alone'
|
||||
import {
|
||||
assertCliConversationWritersExited,
|
||||
type CliWriterHost
|
||||
} from '../runtime/structured-agent-cli-writers'
|
||||
const { fullTable, identityTable, jobMembers, consoleMembers } = vi.hoisted(() => ({
|
||||
fullTable: vi.fn(),
|
||||
identityTable: vi.fn(),
|
||||
jobMembers: vi.fn(),
|
||||
consoleMembers: vi.fn()
|
||||
}))
|
||||
vi.mock('../windows/windows-process-table', () => ({
|
||||
readWindowsProcessTable: fullTable,
|
||||
readWindowsProcessTableFresh: fullTable,
|
||||
readWindowsProcessIdentityTable: identityTable,
|
||||
readWindowsProcessIdentityTableFresh: identityTable
|
||||
}))
|
||||
vi.mock('../providers/windows-pty-job-membership', () => ({
|
||||
readWindowsPtyJobProcessIds: jobMembers,
|
||||
isWindowsPtyJobReadable: () => true
|
||||
}))
|
||||
vi.mock('../providers/windows-console-attached-processes', () => ({
|
||||
readWindowsConsoleAttachedProcessIds: consoleMembers
|
||||
}))
|
||||
|
||||
function createSubprocess(): SubprocessHandle {
|
||||
let onExit: ((code: number) => void) | null = null
|
||||
return {
|
||||
@@ -71,6 +100,177 @@ describe('TerminalHost process inspection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'uses fresh anchored decisions through Session (static=%s)',
|
||||
async (staticName) => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
const nativeRow = (
|
||||
pid: number,
|
||||
ppid: number,
|
||||
name: string,
|
||||
command = name
|
||||
): WindowsProcessRow => ({ pid, ppid, name, command })
|
||||
const shell = nativeRow(100, 1, 'powershell.exe')
|
||||
const orphan = nativeRow(120, 999, 'claude.exe', 'claude.exe --resume conversation')
|
||||
const daemon = nativeRow(130, 1, 'ssh-agent.exe', 'ssh-agent.exe -D')
|
||||
const cases: [string, boolean, WindowsProcessRow[], boolean, string?][] = [
|
||||
['D1', false, [shell, daemon], true],
|
||||
['known young', true, [shell, orphan], false],
|
||||
['known old', true, [shell, orphan], false, 'old'],
|
||||
['foreign anchor', true, [shell, { ...daemon, pid: 120 }], true],
|
||||
['anchor absent', true, [shell, daemon], true],
|
||||
['anchor exited', true, [shell], true],
|
||||
['degraded incomplete', false, [shell, daemon], false, 'both'],
|
||||
['Session exits during foreground read', false, [shell, daemon], false, 'exit']
|
||||
]
|
||||
try {
|
||||
for (const [name, anchored, rows, admits, variant] of cases) {
|
||||
vi.setSystemTime(1000)
|
||||
let dead = false
|
||||
const base = createSubprocess()
|
||||
const proc: IPty = {
|
||||
pid: 100,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
process: 'powershell.exe',
|
||||
handleFlowControl: false,
|
||||
onData: () => ({ dispose() {} }),
|
||||
onExit: () => ({ dispose() {} }),
|
||||
resize: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
write: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
resume: vi.fn()
|
||||
}
|
||||
Object.assign(proc, { processNameIsSpawnFile: staticName })
|
||||
const tracker = createPtyForegroundProcessTracker({
|
||||
process: proc,
|
||||
shellPath: 'powershell.exe',
|
||||
sessionId: 'decision',
|
||||
startupAgentRecognition: null,
|
||||
isDead: () => dead
|
||||
})
|
||||
const getForegroundProcess = vi.fn(tracker.getForegroundProcess)
|
||||
const host = new TerminalHost({
|
||||
spawnSubprocess: () => ({
|
||||
...base,
|
||||
pid: 100,
|
||||
processNameIsSpawnFile: staticName,
|
||||
getForegroundProcess,
|
||||
confirmForegroundProcess: tracker.confirmForegroundProcess,
|
||||
inspectChildProcesses: (fresh, ignoreDetached) =>
|
||||
inspectSpawnFileWindowsChildProcesses(proc, shell.name, fresh, ignoreDetached),
|
||||
onExit: (callback) =>
|
||||
base.onExit((code) => {
|
||||
dead = true
|
||||
tracker.markDead()
|
||||
callback(code)
|
||||
})
|
||||
})
|
||||
})
|
||||
try {
|
||||
const created = await host.createOrAttach({
|
||||
sessionId: 'decision',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
streamClient: { onData: vi.fn(), onExit: vi.fn() }
|
||||
})
|
||||
fullTable
|
||||
.mockReset()
|
||||
.mockResolvedValue([
|
||||
shell,
|
||||
{ ...orphan, ppid: 100 },
|
||||
...(anchored ? [] : [nativeRow(121, 100, 'claude.exe')])
|
||||
])
|
||||
consoleMembers.mockResolvedValue(new Set([100, 120, 121]))
|
||||
expect(await host.confirmForegroundProcess('decision')).toBe('claude')
|
||||
vi.setSystemTime(variant === 'old' ? 41_000 : 2000)
|
||||
fullTable.mockReset().mockImplementation(async () => {
|
||||
if (variant === 'both') {
|
||||
throw new Error('denied')
|
||||
}
|
||||
if (variant === 'exit') {
|
||||
base.kill()
|
||||
}
|
||||
return rows
|
||||
})
|
||||
identityTable.mockReset().mockResolvedValue(variant === 'both' ? [shell] : rows)
|
||||
jobMembers.mockReturnValue(new Set(rows.map((row) => row.pid)))
|
||||
consoleMembers.mockResolvedValue(new Set([100]))
|
||||
getForegroundProcess.mockClear()
|
||||
const terminal = () => ({
|
||||
pane: {
|
||||
ptyId: 'decision',
|
||||
incarnationId: created.incarnationId,
|
||||
identity: 'decision'
|
||||
}
|
||||
})
|
||||
let alone: boolean | undefined
|
||||
const writer: CliWriterHost = {
|
||||
rows: () => [],
|
||||
saved: () => [],
|
||||
observation: () => null,
|
||||
sleeping: () => false,
|
||||
terminal,
|
||||
links: () => [
|
||||
{
|
||||
agent: 'claude',
|
||||
id: 'conversation',
|
||||
paneOwned: true,
|
||||
worktreeId: 'workspace',
|
||||
terminal
|
||||
}
|
||||
],
|
||||
shellAlone: async () => {
|
||||
const inspected = await host.inspectProcess('decision', {
|
||||
expectedIncarnationId: created.incarnationId,
|
||||
scanChildProcesses: true,
|
||||
ignoreDetached: true
|
||||
})
|
||||
alone = inspectionShowsShellAlone(inspected)
|
||||
return alone
|
||||
}
|
||||
}
|
||||
const run = () => assertCliConversationWritersExited(writer, 'claude', 'conversation')
|
||||
const result = expect(run())
|
||||
await (admits
|
||||
? result.resolves.toBeUndefined()
|
||||
: result.rejects.toMatchObject({ refusal: { code: 'agent_session_conflict' } }))
|
||||
expect(alone, name).toBe(admits)
|
||||
expect(fullTable, name).toHaveBeenCalledTimes(1)
|
||||
expect(getForegroundProcess, name).not.toHaveBeenCalled()
|
||||
if (name === 'D1') {
|
||||
for (const count of [2, 20]) {
|
||||
fullTable.mockClear()
|
||||
identityTable.mockClear()
|
||||
for (let i = 0; i < count; i++) {
|
||||
await run()
|
||||
}
|
||||
expect(fullTable).toHaveBeenCalledTimes(count)
|
||||
expect(identityTable).toHaveBeenCalledTimes(count)
|
||||
}
|
||||
expect(
|
||||
inspectionShowsShellAlone(
|
||||
await host.inspectProcess('decision', { ignoreDetached: false })
|
||||
)
|
||||
).toBe(false)
|
||||
}
|
||||
} finally {
|
||||
await host.dispose()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('returns unverifiable when the expected incarnation is stale', async () => {
|
||||
const host = new TerminalHost({ spawnSubprocess: () => createSubprocess() })
|
||||
try {
|
||||
|
||||
@@ -80,7 +80,8 @@ export async function inspectTerminalHostProcess(args: {
|
||||
}
|
||||
args.onTier?.('full')
|
||||
|
||||
let foregroundProcess = session.getForegroundProcess()
|
||||
const windowsDecision = process.platform === 'win32' && args.steadyState !== true
|
||||
let foregroundProcess = windowsDecision ? null : session.getForegroundProcess()
|
||||
let childProcessEvidence: PtyChildProcessVerdict | undefined = session.processNameIsSpawnFile
|
||||
? 'unverifiable'
|
||||
: undefined
|
||||
@@ -88,8 +89,10 @@ export async function inspectTerminalHostProcess(args: {
|
||||
if (!incarnationMatches) {
|
||||
evidence = unverifiableEvidence(args, session, 'incarnation_mismatch')
|
||||
} else if (process.platform === 'win32') {
|
||||
if (session.processNameIsSpawnFile) {
|
||||
foregroundProcess = await session.confirmForegroundProcess()
|
||||
if (windowsDecision || session.processNameIsSpawnFile) {
|
||||
foregroundProcess = await session.confirmForegroundProcess(
|
||||
windowsDecision ? { decision: true } : undefined
|
||||
)
|
||||
}
|
||||
// A steady-state poll never decides adoption or close, so it may read the cached process table.
|
||||
childProcessEvidence = await session.inspectChildProcesses(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getStrictProcessTableSnapshotWithAge
|
||||
} from '../../shared/process-table-snapshot-reader'
|
||||
import { confirmShellForegroundProcess } from './agent-foreground-process'
|
||||
import { confirmFreshPtyForeground } from './pty-foreground-confirmation'
|
||||
import {
|
||||
createPtyForegroundResolver,
|
||||
ptyProcessNameIsSpawnFile
|
||||
@@ -30,7 +31,6 @@ import {
|
||||
canRevalidateCachedAgentWithoutScan,
|
||||
judgeCachedAgentJobEvidence
|
||||
} from './windows-cached-agent-revalidation'
|
||||
import { readWindowsConsoleAttachedProcessIds } from './windows-console-attached-processes'
|
||||
import { isWindowsPtyJobReadable, readWindowsPtyJobProcessIds } from './windows-pty-job-membership'
|
||||
|
||||
/** Retired masters still report the spawn name; inspect the descriptor first so unreadability cannot prove idle. */
|
||||
@@ -241,34 +241,47 @@ async function readPosixSteadyState(
|
||||
}
|
||||
}
|
||||
|
||||
export async function confirmLocalPtyForegroundProcess(id: string): Promise<string | null> {
|
||||
export async function confirmLocalPtyForegroundProcess(
|
||||
id: string,
|
||||
decision = false
|
||||
): Promise<string | null> {
|
||||
const proc = ptyProcesses.get(id)
|
||||
if (!proc) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const resolution = await createPtyForegroundResolver(proc)(
|
||||
proc.pid,
|
||||
resolveForegroundFallbackProcess(
|
||||
const windowsDecision = decision && process.platform === 'win32'
|
||||
const cached = ptyLastRecognizedForeground.get(id)
|
||||
const anchor = cached ? { processName: cached.name, pid: cached.pid } : null
|
||||
const { resolution, processName } = await confirmFreshPtyForeground({
|
||||
process: proc,
|
||||
fallbackProcess: resolveForegroundFallbackProcess(
|
||||
ptyProcessNameIsSpawnFile(proc) ? (getPtyShellName(id) ?? null) : proc.process || null,
|
||||
getPtyShellName(id)
|
||||
),
|
||||
{
|
||||
contextPaths: ptyAgentForegroundContextPaths.get(id),
|
||||
fresh: true,
|
||||
...(process.platform === 'win32'
|
||||
? {
|
||||
forceProcessScan: true,
|
||||
readWindowsConsoleAttachedProcessIds: () =>
|
||||
readWindowsConsoleAttachedProcessIds(proc.pid)
|
||||
}
|
||||
: {})
|
||||
}
|
||||
)
|
||||
contextPaths: ptyAgentForegroundContextPaths.get(id),
|
||||
decision: windowsDecision,
|
||||
anchor,
|
||||
anchorAt: cached?.at
|
||||
})
|
||||
// Why: a fresh scan can outlive this PTY id; never publish identity from an exited or same-id-reusing session.
|
||||
if (ptyProcesses.get(id) !== proc) {
|
||||
return null
|
||||
}
|
||||
if (windowsDecision) {
|
||||
const recognized = recognizeAgentProcessFromCommandLine(resolution.processName ?? '')
|
||||
if (resolution.available && recognized) {
|
||||
ptyLastRecognizedForeground.set(id, {
|
||||
name: recognized.processName,
|
||||
pid: resolution.processId ?? null,
|
||||
at: Date.now(),
|
||||
steady: null
|
||||
})
|
||||
} else if (processName !== anchor?.processName) {
|
||||
ptyLastRecognizedForeground.delete(id)
|
||||
}
|
||||
return processName
|
||||
}
|
||||
return resolution.available ? resolution.processName : null
|
||||
} catch {
|
||||
return null
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { assert, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as MacosTccLoginShell from './macos-tcc-login-shell'
|
||||
import type * as ForegroundProcess from './agent-foreground-process'
|
||||
import type { WindowsProcessRow } from '../windows/windows-process-table'
|
||||
import {
|
||||
ptyLastRecognizedForeground,
|
||||
ptyProcesses,
|
||||
ptyIncarnations
|
||||
} from './local-pty-provider-state'
|
||||
import { inspectionShowsShellAlone } from '../runtime/run-terminal-shell-alone'
|
||||
import {
|
||||
assertCliConversationWritersExited,
|
||||
type CliWriterHost
|
||||
} from '../runtime/structured-agent-cli-writers'
|
||||
|
||||
const {
|
||||
fullTable,
|
||||
identityTable,
|
||||
consoleMembers,
|
||||
existsSyncMock,
|
||||
statSyncMock,
|
||||
accessSyncMock,
|
||||
@@ -16,6 +31,9 @@ const {
|
||||
wslUncDirectoryExistsAsyncMock,
|
||||
createShellPromptReadinessProbeMock
|
||||
} = vi.hoisted(() => ({
|
||||
fullTable: vi.fn(),
|
||||
identityTable: vi.fn(),
|
||||
consoleMembers: vi.fn(),
|
||||
existsSyncMock: vi.fn(),
|
||||
statSyncMock: vi.fn(),
|
||||
accessSyncMock: vi.fn(),
|
||||
@@ -76,7 +94,8 @@ vi.mock('./windows-powershell-executable', () => ({
|
||||
getWindowsCmdPath: () => CMD_ABS
|
||||
}))
|
||||
|
||||
vi.mock('./agent-foreground-process', () => ({
|
||||
vi.mock('./agent-foreground-process', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof ForegroundProcess>()),
|
||||
resolveAgentForegroundProcessWithAvailability: (...args: unknown[]) =>
|
||||
resolveAgentForegroundProcessMock(...args)
|
||||
}))
|
||||
@@ -86,6 +105,16 @@ vi.mock('./windows-pty-job-membership', () => ({
|
||||
isWindowsPtyJobReadable: () => true
|
||||
}))
|
||||
|
||||
vi.mock('../windows/windows-process-table', () => ({
|
||||
readWindowsProcessTable: fullTable,
|
||||
readWindowsProcessTableFresh: fullTable,
|
||||
readWindowsProcessIdentityTable: identityTable,
|
||||
readWindowsProcessIdentityTableFresh: identityTable
|
||||
}))
|
||||
vi.mock('./windows-console-attached-processes', () => ({
|
||||
readWindowsConsoleAttachedProcessIds: consoleMembers
|
||||
}))
|
||||
|
||||
vi.mock('../wsl', () => ({
|
||||
parseWslPath: (path: string) => {
|
||||
const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/)
|
||||
@@ -187,6 +216,193 @@ describe('LocalPtyProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'reconciles real Windows decision evidence (static=%s)',
|
||||
async (staticName) => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const actual = await vi.importActual<typeof ForegroundProcess>('./agent-foreground-process')
|
||||
resolveAgentForegroundProcessMock.mockImplementation(
|
||||
actual.resolveAgentForegroundProcessWithAvailability
|
||||
)
|
||||
mockProc.pid = 100
|
||||
mockProc.process = 'powershell.exe'
|
||||
Object.assign(mockProc, { processNameIsSpawnFile: staticName })
|
||||
const { id, incarnationId } = await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shellOverride: 'powershell.exe'
|
||||
})
|
||||
assert(incarnationId, 'fixture incarnation missing')
|
||||
const nativeRow = (
|
||||
pid: number,
|
||||
ppid: number,
|
||||
name: string,
|
||||
command = name
|
||||
): WindowsProcessRow => ({ pid, ppid, name, command, creationTimeMs: pid })
|
||||
const shell = nativeRow(100, 1, 'powershell.exe')
|
||||
const orphan = nativeRow(120, 999, 'claude.exe', 'claude.exe --resume conversation')
|
||||
const daemon = nativeRow(130, 1, 'ssh-agent.exe', 'ssh-agent.exe -D')
|
||||
type EvidenceOptions = {
|
||||
age?: number
|
||||
detailed?: WindowsProcessRow[]
|
||||
identities?: WindowsProcessRow[]
|
||||
scanFailed?: boolean
|
||||
jobFailed?: boolean
|
||||
consoleFailed?: boolean
|
||||
childFailed?: boolean
|
||||
}
|
||||
const cases: [string, number | null, WindowsProcessRow[], boolean, EvidenceOptions?][] = [
|
||||
['C1', null, [shell, daemon], true],
|
||||
['live young anchor', 120, [shell, orphan], false],
|
||||
['live old anchor', 120, [shell, orphan], false, { age: 40_000 }],
|
||||
['anchor identity missing', 120, [shell, orphan], false, { detailed: [shell] }],
|
||||
['anchor query denied', 120, [shell, nativeRow(120, 999, 'unknown.exe', '')], false],
|
||||
['anchored scan failed', 120, [shell, orphan], false, { scanFailed: true }],
|
||||
['job unavailable', 120, [shell, orphan], false, { jobFailed: true }],
|
||||
['anchor absent', 120, [shell, daemon], true],
|
||||
['anchor just exited', 120, [shell], true],
|
||||
[
|
||||
'foreign anchor',
|
||||
120,
|
||||
[shell, nativeRow(120, 999, 'ssh-agent.exe', 'ssh-agent.exe -D')],
|
||||
true
|
||||
],
|
||||
['attached agent', null, [shell, { ...orphan, ppid: 100 }], false],
|
||||
['ordinary background job', null, [shell, nativeRow(130, 100, 'sleep.exe')], false],
|
||||
['incomplete identity', null, [shell, daemon], false, { identities: [shell] }],
|
||||
['console unavailable', null, [shell, daemon], false, { consoleFailed: true }],
|
||||
['child read failed', null, [shell, daemon], false, { childFailed: true }],
|
||||
['unanchored degraded complete', null, [shell, daemon], true, { scanFailed: true }],
|
||||
[
|
||||
'unanchored degraded incomplete',
|
||||
null,
|
||||
[shell, daemon],
|
||||
false,
|
||||
{ scanFailed: true, identities: [shell] }
|
||||
]
|
||||
]
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
try {
|
||||
const seed = (pid: number | null) =>
|
||||
ptyLastRecognizedForeground.set(id, { name: 'claude', pid, at: 1000, steady: null })
|
||||
for (const [name, anchor, rows, admits, input = {}] of cases) {
|
||||
vi.setSystemTime(1000 + (input.age ?? 1000))
|
||||
seed(anchor)
|
||||
readWindowsPtyJobProcessIdsMock.mockReturnValue(
|
||||
input.jobFailed ? null : new Set(rows.map((row) => row.pid))
|
||||
)
|
||||
fullTable.mockReset().mockResolvedValue(input.detailed ?? rows)
|
||||
if (input.scanFailed) {
|
||||
fullTable.mockRejectedValue(new Error('denied'))
|
||||
}
|
||||
identityTable.mockReset().mockResolvedValue(input.identities ?? rows)
|
||||
if (input.childFailed) {
|
||||
identityTable.mockRejectedValue(new Error('denied'))
|
||||
}
|
||||
consoleMembers.mockResolvedValue(input.consoleFailed ? null : new Set([100]))
|
||||
let alone: boolean | undefined
|
||||
const host: CliWriterHost = {
|
||||
rows: () => [
|
||||
{
|
||||
paneKey: 'source',
|
||||
connectionId: null,
|
||||
worktreeId: 'workspace',
|
||||
agentType: 'claude',
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
receivedAt: 1,
|
||||
stateStartedAt: 1,
|
||||
providerSession: { key: 'session_id', id: 'conversation' }
|
||||
}
|
||||
],
|
||||
saved: () => [],
|
||||
links: () => [],
|
||||
sleeping: () => false,
|
||||
observation: () => ({
|
||||
current: () => true,
|
||||
topology: 'pane-owned',
|
||||
presence: async () => null
|
||||
}),
|
||||
terminal: () => ({ pane: { ptyId: id, incarnationId, identity: id + incarnationId } }),
|
||||
shellAlone: async () => {
|
||||
const observed = await provider.inspectProcess(id, {
|
||||
expectedIncarnationId: incarnationId,
|
||||
scanChildProcesses: true,
|
||||
ignoreDetached: true
|
||||
})
|
||||
alone = inspectionShowsShellAlone(observed)
|
||||
return alone
|
||||
}
|
||||
}
|
||||
const result = expect(assertCliConversationWritersExited(host, 'claude', 'conversation'))
|
||||
await (admits
|
||||
? result.resolves.toBeUndefined()
|
||||
: result.rejects.toMatchObject({ refusal: { code: 'agent_session_conflict' } }))
|
||||
expect(alone, name).toBe(admits)
|
||||
expect(fullTable, name).toHaveBeenCalledTimes(1)
|
||||
expect(identityTable.mock.calls.length, name).toBe(
|
||||
input.jobFailed || rows.length === 1 ? 0 : 1
|
||||
)
|
||||
}
|
||||
fullTable.mockReset().mockResolvedValue([shell, daemon])
|
||||
identityTable.mockReset().mockResolvedValue([shell, daemon])
|
||||
consoleMembers.mockResolvedValue(new Set([100]))
|
||||
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([100, 130]))
|
||||
for (const count of [2, 20]) {
|
||||
fullTable.mockClear()
|
||||
identityTable.mockClear()
|
||||
for (let i = 0; i < count; i++) {
|
||||
seed(null)
|
||||
expect(
|
||||
inspectionShowsShellAlone(await provider.inspectProcess(id, { ignoreDetached: true }))
|
||||
).toBe(true)
|
||||
}
|
||||
expect(fullTable).toHaveBeenCalledTimes(count)
|
||||
expect(identityTable).toHaveBeenCalledTimes(count)
|
||||
}
|
||||
seed(null)
|
||||
fullTable.mockClear()
|
||||
expect(await provider.getForegroundProcess(id)).toBe('claude')
|
||||
await expect(
|
||||
provider.inspectProcess(id, { steadyState: true, ignoreDetached: true })
|
||||
).resolves.toMatchObject({ foregroundProcess: 'claude' })
|
||||
expect(fullTable).not.toHaveBeenCalled()
|
||||
expect(
|
||||
inspectionShowsShellAlone(await provider.inspectProcess(id, { ignoreDetached: false }))
|
||||
).toBe(false)
|
||||
seed(120)
|
||||
fullTable.mockResolvedValue([shell, orphan])
|
||||
identityTable.mockResolvedValue([shell, orphan])
|
||||
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([100, 120]))
|
||||
expect(await provider.confirmForegroundProcess(id)).not.toBe('claude')
|
||||
for (const replacement of ['object', 'incarnation']) {
|
||||
const original = ptyProcesses.get(id)
|
||||
if (!original) {
|
||||
throw new Error('fixture PTY missing')
|
||||
}
|
||||
fullTable.mockImplementationOnce(async () => {
|
||||
if (replacement === 'object') {
|
||||
ptyProcesses.set(id, { ...original })
|
||||
} else {
|
||||
ptyIncarnations.set(id, 'replacement')
|
||||
}
|
||||
return [shell]
|
||||
})
|
||||
await expect(
|
||||
provider.inspectProcess(id, {
|
||||
expectedIncarnationId: incarnationId,
|
||||
ignoreDetached: true
|
||||
})
|
||||
).resolves.toMatchObject({ childProcessEvidence: 'unverifiable' })
|
||||
ptyProcesses.set(id, original)
|
||||
ptyIncarnations.set(id, incarnationId)
|
||||
}
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
describe('getForegroundProcess', () => {
|
||||
it('returns the process name', async () => {
|
||||
const { id } = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
@@ -157,7 +157,9 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
childProcessEvidence: 'unverifiable'
|
||||
}
|
||||
}
|
||||
const foregroundProcess = await getLocalPtyForegroundProcess(id)
|
||||
const foregroundProcess = await (process.platform === 'win32' && options?.steadyState !== true
|
||||
? confirmLocalPtyForegroundProcess(id, true)
|
||||
: getLocalPtyForegroundProcess(id))
|
||||
// A steady-state poll never decides adoption or close, so it may read the cached process table.
|
||||
const childProcessEvidence = await inspectLocalPtyChildProcesses(
|
||||
id,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
|
||||
export type { LoginPreflightOutcome } from './macos-login-session-pty-probe'
|
||||
|
||||
const MACOS_LOGIN_PATH = '/usr/bin/login'
|
||||
export const MACOS_LOGIN_PATH = '/usr/bin/login'
|
||||
const MACOS_BASH_PATH = '/bin/bash'
|
||||
const MACOS_PRINTF_PATH = '/usr/bin/printf'
|
||||
const LOGIN_SHELL_TRAMPOLINE = 'export SHELL="$1"; shift; exec -l -- "$@"'
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { IPty } from 'node-pty'
|
||||
import { recognizeAgentProcess } from '../../shared/agent-process-recognition'
|
||||
import { shouldInspectOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent'
|
||||
import { ptyShellProcessId } from '../windows/windows-pty-job'
|
||||
import { createPtyForegroundResolver } from '../daemon/pty-subprocess/spawn-file-foreground-process'
|
||||
import {
|
||||
resolveAgentForegroundProcessWithAvailability,
|
||||
type AgentForegroundResolutionOptions,
|
||||
type AgentForegroundProcessResolution
|
||||
} from './agent-foreground-process'
|
||||
import {
|
||||
judgeCachedAgentJobEvidence,
|
||||
reconcileWindowsAgentDecision,
|
||||
type WindowsCachedAgentJobVerdict
|
||||
} from './windows-cached-agent-revalidation'
|
||||
import { readWindowsConsoleAttachedProcessIds } from './windows-console-attached-processes'
|
||||
import { isWindowsPtyJobReadable, readWindowsPtyJobProcessIds } from './windows-pty-job-membership'
|
||||
|
||||
/** Execute fresh confirmation; owners retain cache publication and lifetime fences. */
|
||||
export async function confirmFreshPtyForeground(args: {
|
||||
process: IPty
|
||||
fallbackProcess: string | null
|
||||
contextPaths: AgentForegroundResolutionOptions['contextPaths']
|
||||
decision: boolean
|
||||
anchor: { processName: string; pid: number | null } | null
|
||||
anchorAt?: number
|
||||
}): Promise<{ resolution: AgentForegroundProcessResolution; processName: string | null }> {
|
||||
const proc = args.process
|
||||
const shellPid = ptyShellProcessId(proc) ?? proc.pid
|
||||
let verdict: WindowsCachedAgentJobVerdict = 'unavailable'
|
||||
if (args.decision) {
|
||||
try {
|
||||
verdict = judgeCachedAgentJobEvidence({
|
||||
jobProcessIds: readWindowsPtyJobProcessIds(proc),
|
||||
jobSupported: isWindowsPtyJobReadable(),
|
||||
shellPid,
|
||||
anchorProcessId: args.anchor?.pid ?? null,
|
||||
identityAgeMs: Date.now() - (args.anchorAt ?? 0)
|
||||
})
|
||||
} catch {
|
||||
/* A failed job read cannot release a known anchor. */
|
||||
}
|
||||
}
|
||||
let resolution: AgentForegroundProcessResolution
|
||||
try {
|
||||
const resolve = args.decision
|
||||
? resolveAgentForegroundProcessWithAvailability
|
||||
: createPtyForegroundResolver(proc)
|
||||
resolution = await resolve(proc.pid, args.fallbackProcess, {
|
||||
contextPaths: args.contextPaths,
|
||||
fresh: true,
|
||||
...(args.decision && args.anchor?.pid != null
|
||||
? { anchorProcessId: args.anchor.pid, anchorProcessName: args.anchor.processName }
|
||||
: {}),
|
||||
...(process.platform === 'win32'
|
||||
? {
|
||||
forceProcessScan: true,
|
||||
readWindowsConsoleAttachedProcessIds: () =>
|
||||
readWindowsConsoleAttachedProcessIds(proc.pid)
|
||||
}
|
||||
: {})
|
||||
})
|
||||
} catch {
|
||||
resolution = { available: false, processName: null }
|
||||
}
|
||||
return {
|
||||
resolution,
|
||||
processName: args.decision
|
||||
? reconcileWindowsAgentDecision(resolution, verdict, args.anchor, shellPid)
|
||||
: resolution.available
|
||||
? resolution.processName
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
/** Known direct agent names need no POSIX scan; Windows decisions still require fresh evidence. */
|
||||
export function shouldConfirmPtyForeground(fallback: string | null, decision: boolean): boolean {
|
||||
if (!fallback) {
|
||||
return decision
|
||||
}
|
||||
const recognized = recognizeAgentProcess(fallback)
|
||||
return (
|
||||
process.platform === 'win32' ||
|
||||
recognized === null ||
|
||||
shouldInspectOuterWrapperForegroundProcess(recognized)
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { isShellProcess } from '../../shared/shell-process-detection'
|
||||
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
|
||||
import type { AgentForegroundProcessResolution } from './agent-foreground-process'
|
||||
|
||||
/** Unanchored job evidence may outlive console agents; cap it at five renderer confirm ladders (30s).
|
||||
* Refresh on every positive recognition so the limit never times out a confirmed live agent. */
|
||||
@@ -58,3 +60,28 @@ export function judgeCachedAgentJobEvidence(args: {
|
||||
}
|
||||
return withinAgeBound ? 'unproven' : 'expired'
|
||||
}
|
||||
|
||||
/** Decision authority comes from fresh recognition or a known anchor not yet proved gone. */
|
||||
export function reconcileWindowsAgentDecision(
|
||||
resolution: AgentForegroundProcessResolution,
|
||||
verdict: WindowsCachedAgentJobVerdict,
|
||||
anchor: { processName: string; pid: number | null } | null,
|
||||
shellPid: number
|
||||
): string | null {
|
||||
if (resolution.available && recognizeAgentProcessFromCommandLine(resolution.processName ?? '')) {
|
||||
return resolution.processName
|
||||
}
|
||||
if (
|
||||
anchor?.pid != null &&
|
||||
anchor.pid !== shellPid &&
|
||||
recognizeAgentProcessFromCommandLine(anchor.processName) &&
|
||||
!resolution.anchorPidForeign &&
|
||||
(verdict === 'confirmed' ||
|
||||
verdict === 'recheck' ||
|
||||
verdict === 'unavailable' ||
|
||||
verdict === 'unsupported')
|
||||
) {
|
||||
return anchor.processName
|
||||
}
|
||||
return resolution.available ? resolution.processName : null
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type * as ProcessTableReader from '../../shared/process-table-snapshot-reader'
|
||||
import { hostSleepKey } from './terminal/stop/worktree-terminal-sleep-key'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentStatusIpcPayload } from '../../shared/agent-status-types'
|
||||
@@ -7,13 +8,14 @@ import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resu
|
||||
import type { EnrichedAgentHookEventPayload } from '../agent-hooks/server/server-types'
|
||||
import {
|
||||
assertCliConversationWritersExited,
|
||||
posixCliWriterShellAlone,
|
||||
type CliWriterHost
|
||||
} from './structured-agent-cli-writers'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import type { RuntimeStore } from './runtime-store-contract'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
|
||||
import { getDefaultWorkspaceSession } from '../../shared/constants'
|
||||
import { bindPaneFirst } from './bind-first-pane.test-fixture'
|
||||
import { asProfileSessionStore } from './profile-session-store.test-fixture'
|
||||
import {
|
||||
OPEN_CODE_WRITER_TOPOLOGY,
|
||||
tmuxCliWriterEvent
|
||||
@@ -95,6 +97,108 @@ function writerLayoutStore(): RuntimeStore {
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
describe('execution-host conversation writer inspection', () => {
|
||||
it('looks through the macOS login wrapper to the shell it started', async () => {
|
||||
// Captured from a real macOS pane: login(1) stays as the shell's parent.
|
||||
const wrapped = [
|
||||
{
|
||||
pid: 10,
|
||||
ppid: 1,
|
||||
stat: 'Ss',
|
||||
command: '/usr/bin/login -flpq user /bin/bash --noprofile --norc -p -c orca-tcc-login'
|
||||
},
|
||||
{ pid: 11, ppid: 10, stat: 'S+', command: '-/bin/zsh -l' }
|
||||
]
|
||||
snapshot.mockResolvedValue(wrapped)
|
||||
await expect(posixCliWriterShellAlone(10)).resolves.toBe(true)
|
||||
for (const rows of [
|
||||
[wrapped[0]],
|
||||
[...wrapped, { ...wrapped[1], pid: 13 }],
|
||||
[wrapped[0], { ...wrapped[1], command: 'claude' }],
|
||||
[wrapped[0], { ...wrapped[1], stat: 'Ss' }]
|
||||
]) {
|
||||
snapshot.mockResolvedValue(rows)
|
||||
await expect(posixCliWriterShellAlone(10)).resolves.toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(
|
||||
process.platform !== 'darwin' ||
|
||||
process.env.GITHUB_ACTIONS !== 'true' ||
|
||||
process.env.RUNNER_ENVIRONMENT !== 'github-hosted' ||
|
||||
process.env.ORCA_MACOS_LOGIN_CENSUS_CI !== '1'
|
||||
)(
|
||||
'censuses a real login PTY on a disposable hosted Mac',
|
||||
async () => {
|
||||
expect(process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL).toBeUndefined()
|
||||
const login = await import('../providers/macos-tcc-login-shell')
|
||||
expect(await login.prepareMacosTccLoginShell()).toMatchObject({
|
||||
ok: true,
|
||||
reason: 'accepted'
|
||||
})
|
||||
const wrapped = login.wrapShellSpawnForMacosTccAttribution('/bin/bash', [
|
||||
'--noprofile',
|
||||
'--norc',
|
||||
'-i'
|
||||
])
|
||||
expect(wrapped.file).toBe('/usr/bin/login')
|
||||
const real = await vi.importActual<typeof ProcessTableReader>(
|
||||
'../../shared/process-table-snapshot-reader'
|
||||
)
|
||||
snapshot.mockImplementation(real.getFreshShellForegroundSnapshot)
|
||||
const { spawn } = await import('node-pty')
|
||||
const proc = spawn(wrapped.file, wrapped.args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
env: { PATH: '/usr/bin:/bin', TERM: 'xterm-256color' }
|
||||
})
|
||||
let exited = false
|
||||
const subscription = proc.onExit(() => {
|
||||
exited = true
|
||||
})
|
||||
const idle = () => posixCliWriterShellAlone(proc.pid)
|
||||
const rows = () => real.getFreshShellForegroundSnapshot()
|
||||
try {
|
||||
await expect.poll(idle, { timeout: 15000 }).toBe(true)
|
||||
const initial = await rows()
|
||||
expect(initial.find((row) => row.pid === proc.pid)?.command).toMatch(/^\/usr\/bin\/login /)
|
||||
const children = initial.filter((row) => row.ppid === proc.pid)
|
||||
expect(children).toHaveLength(1)
|
||||
expect(children[0].command).toMatch(/bash/)
|
||||
expect(children[0].stat).toContain('+')
|
||||
for (const kind of ['foreground', 'background', 'suspended']) {
|
||||
proc.write(kind === 'foreground' ? '/bin/sleep 30\r' : '/bin/sleep 30 & job=$!\r')
|
||||
const jobObserved = async (stopped = false) =>
|
||||
(await rows()).some(
|
||||
(row) =>
|
||||
row.ppid === children[0].pid &&
|
||||
row.command === '/bin/sleep 30' &&
|
||||
(!stopped || row.stat.includes('T'))
|
||||
)
|
||||
await expect.poll(() => jobObserved(), { timeout: 15000 }).toBe(true)
|
||||
if (kind === 'suspended') {
|
||||
proc.write('kill -STOP "$job"\r')
|
||||
await expect.poll(() => jobObserved(true), { timeout: 15000 }).toBe(true)
|
||||
}
|
||||
expect(await idle()).toBe(false)
|
||||
proc.write(
|
||||
kind === 'foreground'
|
||||
? '\x03'
|
||||
: 'kill -CONT "$job"; kill "$job"; wait "$job"; unset job\r'
|
||||
)
|
||||
await expect.poll(idle, { timeout: 15000 }).toBe(true)
|
||||
}
|
||||
} finally {
|
||||
// Only this shell's recorded job is signalled; login exits through its own shell.
|
||||
proc.write('\x03')
|
||||
proc.write('if [ -n "$job" ]; then kill -CONT "$job"; kill "$job"; wait "$job"; fi; exit\r')
|
||||
await expect.poll(() => exited, { timeout: 15000 }).toBe(true)
|
||||
subscription.dispose()
|
||||
}
|
||||
},
|
||||
120000
|
||||
)
|
||||
|
||||
it('accepts exact exited or unchanged shell-only proof, never loss of evidence', async () => {
|
||||
await expect(inspection('unverifiable').run()).rejects.toMatchObject(refusal)
|
||||
for (const cause of ['missing-row', 'sleeping', 'failed-scan', 'unknown-topology'] as const) {
|
||||
@@ -403,11 +507,26 @@ describe('execution-host conversation writer inspection', () => {
|
||||
await expect(noRows.run()).rejects.toMatchObject(refusal)
|
||||
})
|
||||
|
||||
it('inspects the terminal a conversation leaves, whatever its rows now say', async () => {
|
||||
it.each([
|
||||
['darwin', true],
|
||||
['darwin', false],
|
||||
['linux', false]
|
||||
] as const)('inspects %s CLI consumers (login=%s)', async (platform, wrapped) => {
|
||||
const leaf = '11111111-1111-4111-8111-111111111111'
|
||||
const pane = `tab-a:${leaf}`
|
||||
let rows: AgentStatusIpcPayload[] = []
|
||||
const store = ORCA_LAYOUT_OWNER_IS_MAIN ? writerLayoutStore() : null
|
||||
const store = writerLayoutStore()
|
||||
const sessions = asProfileSessionStore(store, 'local')
|
||||
// The hook shell captures $PPID, the CLI, before starting its helper shell.
|
||||
const cliProcess = {
|
||||
pid: 82801,
|
||||
platform,
|
||||
startTime:
|
||||
platform === 'darwin'
|
||||
? 'Mon Oct 26 03:00:00 2026'
|
||||
: '11111111-2222-4333-8444-555555555555:123456'
|
||||
}
|
||||
probe.mockResolvedValue('exited')
|
||||
class WriterRuntime extends OrcaRuntimeService {
|
||||
readonly sleepStates = this.terminalSleepStateByHostWorktree
|
||||
}
|
||||
@@ -416,7 +535,7 @@ describe('execution-host conversation writer inspection', () => {
|
||||
readHookCliWriterObservation: () => ({
|
||||
current: () => true,
|
||||
topology: 'pane-owned',
|
||||
presence: async () => null
|
||||
presence: async () => probe(cliProcess)
|
||||
})
|
||||
})
|
||||
const register = (tabId: string, incarnationId?: string, isWsl?: boolean) => {
|
||||
@@ -428,7 +547,9 @@ describe('execution-host conversation writer inspection', () => {
|
||||
register('tab-a', 'i1')
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only listProcesses.
|
||||
runtime.setPtyController({
|
||||
listProcesses: async () => [{ id: 'pty', rootProcessId: 10, incarnationId: 'i1' }]
|
||||
listProcesses: async () => [
|
||||
{ id: 'pty', rootProcessId: wrapped ? 82718 : 82760, incarnationId: 'i1' }
|
||||
]
|
||||
} as never)
|
||||
const event = (agentType: string, extra?: Partial<EnrichedAgentHookEventPayload>) => ({
|
||||
paneKey: pane,
|
||||
@@ -437,6 +558,9 @@ describe('execution-host conversation writer inspection', () => {
|
||||
stateStartedAt: 1,
|
||||
providerSession: { key: 'session_id' as const, id: 'conversation' },
|
||||
payload: { state: 'done' as const, prompt: '', agentType },
|
||||
...(agentType === 'claude'
|
||||
? { agentPresence: { agent: 'claude' as const, process: cliProcess } }
|
||||
: {}),
|
||||
...extra
|
||||
})
|
||||
const report = (agentType: string, extra?: Partial<EnrichedAgentHookEventPayload>) =>
|
||||
@@ -445,16 +569,40 @@ describe('execution-host conversation writer inspection', () => {
|
||||
agent: 'claude' | 'opencode',
|
||||
source?: { workspaceId: string; paneKey: string }
|
||||
) => runtime.assertStructuredAdoptionTerminalWritersExited(agent, 'conversation', source)
|
||||
const shell = { pid: 10, ppid: 1, stat: 'Ss+', command: '/bin/zsh' }
|
||||
const shell = {
|
||||
pid: 82760,
|
||||
ppid: wrapped ? 82718 : 1,
|
||||
pgid: 82760,
|
||||
tpgid: 82760,
|
||||
stat: 'S+',
|
||||
command: platform === 'darwin' ? '-/bin/zsh -l' : '/bin/bash'
|
||||
}
|
||||
const launch = wrapped
|
||||
? [
|
||||
{
|
||||
pid: 82718,
|
||||
ppid: 46400,
|
||||
pgid: 82718,
|
||||
tpgid: 82760,
|
||||
stat: 'Ss',
|
||||
command:
|
||||
'/usr/bin/login -flpq user /bin/bash --noprofile --norc -p -c orca-tcc-login /bin/zsh /bin/zsh -l'
|
||||
}
|
||||
]
|
||||
: []
|
||||
const census = async (run: () => Promise<void>) => {
|
||||
// A prompt's zpty worker left the session; any suspended or background job counts, whatever its name.
|
||||
const worker = [
|
||||
...launch,
|
||||
shell,
|
||||
{ pid: 20, ppid: 10, stat: 'Ss+', command: '-zsh -i' },
|
||||
{ pid: 20, ppid: 82760, stat: 'Ss+', command: '-zsh -i' },
|
||||
{ pid: 21, ppid: 20, stat: 'S+', command: 'git status' }
|
||||
]
|
||||
for (const stat of ['T', 'SN']) {
|
||||
snapshot.mockResolvedValue([...worker, { pid: 22, ppid: 10, stat, command: 'agent-alias' }])
|
||||
for (const stat of ['S+', 'T', 'SN']) {
|
||||
snapshot.mockResolvedValue([
|
||||
...worker,
|
||||
{ pid: 82801, ppid: 82760, stat, command: 'agent-alias' }
|
||||
])
|
||||
await expect(run()).rejects.toMatchObject(refusal)
|
||||
}
|
||||
snapshot.mockResolvedValue(worker)
|
||||
@@ -480,18 +628,40 @@ describe('execution-host conversation writer inspection', () => {
|
||||
runtime.sleepStates.delete(hostSleepKey('local', 'workspace'))
|
||||
}
|
||||
runtime.sleepStates.clear()
|
||||
// A row restored after restart has no observed identity; the pane's current PTY decides.
|
||||
// The current row rechecks the exited CLI identity before inspecting its PTY.
|
||||
rows = [{ ...row, paneKey: pane, agentType: 'claude' }]
|
||||
await census(() => check('claude'))
|
||||
const saved: SleepingAgentSessionRecord = {
|
||||
paneKey: pane,
|
||||
worktreeId: 'workspace',
|
||||
agent: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'conversation' },
|
||||
prompt: '',
|
||||
state: 'done',
|
||||
origin: 'live',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
sessions.set(
|
||||
{ ...sessions.get('local'), sleepingAgentSessionsByPaneKey: { [pane]: saved } },
|
||||
'local'
|
||||
)
|
||||
await census(() => check('claude'))
|
||||
rows = []
|
||||
await census(() => check('claude'))
|
||||
sessions.set({ ...sessions.get('local'), sleepingAgentSessionsByPaneKey: {} }, 'local')
|
||||
expect(probe).toHaveBeenCalledWith(cliProcess)
|
||||
// Paused, then a second run overwrote the row and the pane moved: the link is the execution's.
|
||||
report('claude')
|
||||
rows = [{ ...row, paneKey: pane, agentType: 'claude' }]
|
||||
await census(() => check('claude'))
|
||||
rows = [{ ...rows[0], providerSession: { key: 'session_id', id: 'later-run' } }]
|
||||
report('claude', { providerSession: { key: 'session_id', id: 'later-run' } })
|
||||
register('tab-b', 'i1')
|
||||
await census(() => check('claude'))
|
||||
// The agent's own process can outlive the terminal's tree, as in detached tmux or nohup.
|
||||
report('claude', {
|
||||
agentPresence: { agent: 'claude', process: { pid: 12, platform: 'darwin', startTime: 's' } }
|
||||
agentPresence: { agent: 'claude', process: cliProcess }
|
||||
})
|
||||
for (const verdict of ['live', 'unverifiable', 'exited'] as const) {
|
||||
probe.mockResolvedValueOnce(verdict)
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { CliWriterObservation } from '../agent-hooks/server/cli-writer-obse
|
||||
import { getFreshShellForegroundSnapshot } from '../../shared/process-table-snapshot-reader'
|
||||
import { collectDescendantsFromIndex, getProcessTableIndex } from '../../shared/process-table-index'
|
||||
import { isShellProcess } from '../../shared/shell-process-detection'
|
||||
import { MACOS_LOGIN_PATH } from '../providers/macos-tcc-login-shell'
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import type { AgentProcessIdentity } from '../../shared/agent-process-presence'
|
||||
import { probeAgentProcessPresence } from '../../shared/agent-process-presence-probe'
|
||||
@@ -48,14 +49,20 @@ export type CliWriterHost = {
|
||||
/** Unlike foreground ownership, any job left in the shell's session refuses, background and suspended too. */
|
||||
export async function posixCliWriterShellAlone(rootPid: number): Promise<boolean> {
|
||||
const index = getProcessTableIndex(await getFreshShellForegroundSnapshot())
|
||||
const executable = (command: string) => command.trim().replace(/^-/, '').split(/\s+/, 1)[0] ?? ''
|
||||
const root = index.byPid.get(rootPid)
|
||||
const executable = root?.command.trim().replace(/^-/, '').split(/\s+/, 1)[0] ?? ''
|
||||
// macOS panes spawn under login(1), which stays as the shell's parent; it is launch plumbing.
|
||||
const launched =
|
||||
root && executable(root.command) === MACOS_LOGIN_PATH
|
||||
? (index.childrenByPpid.get(rootPid) ?? [])
|
||||
: undefined
|
||||
const shell = launched ? (launched.length === 1 ? launched[0] : undefined) : root
|
||||
return (
|
||||
!!root &&
|
||||
isShellProcess(executable) &&
|
||||
root.stat.includes('+') &&
|
||||
!!shell &&
|
||||
isShellProcess(executable(shell.command)) &&
|
||||
shell.stat.includes('+') &&
|
||||
// A session leader below the shell has left this terminal (a prompt's zpty worker); its jobs never do.
|
||||
collectDescendantsFromIndex(index, rootPid, (row) => row.stat.includes('s')).length === 0
|
||||
collectDescendantsFromIndex(index, shell.pid, (row) => row.stat.includes('s')).length === 0
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user