Files
orca/src/shared/process-table-index.ts
T
Brennan Benson 2db7d5aac4 Refuse chat resume until terminal writers have exited (#29242)
* fix(native-chat): identify the owner when resume create is refused

* test(native-chat): consolidate transcript refusal cases

* test(native-chat): keep ownership coverage at the launch boundary

* test(native-chat): update adoption fixture for early ownership admission

* test(native-chat): align resume admission assertions with host ownership

* test(native-chat): keep RPC coverage focused on ownership proof

* test(native-chat): assert blank request presence before optional source

* fix(native-chat): admit the actual CLI source during chat creation

* fix(native-chat): satisfy source admission lint checks

* test(native-chat): provide the waiting hook prompt

* test(native-chat): isolate source admission fixtures from host homes

* test(native-chat): canonicalize adoption fixture directories

* test(native-chat): preserve early ownership admission in source stack

* Resume CLI conversations directly in native chat

* fix(native-chat): preserve resume refusal behavior and honest transcript feedback

* fix(native-chat): retain import read evidence in typed result

* Use the header test fixture record type

* Consolidate launch owner imports and correct feedback assertion

* Keep existing resume translation in runtime catalog

* fix(native-chat): preserve admitted source and prepared execution home

* fix(chat): retain source evidence for dispatched adoption

* fix(chat): keep source capture within module limits

* Keep deferred chat publication in the shared launch owner

* test(chat): allow host evidence in create routing

* Satisfy launch publication checks and scope slot cleanup

* Preserve canonical tab membership on projected reorders

* Use recorded launch ownership for surface recovery

* Reserve deferred chat order through existing group actions

* Pass the active layout owner into launch cancellation

* Stop terminal writer before adopting conversation in chat

* Guard chat adoption completion and satisfy static checks

* Switch completed terminal turns without confirmation and test handoff

* Recheck terminal writers on reserved adoption retry

* Split session entry delegates and fix linted test types

* Keep chat opening send below create module line limit

* Require explicit terminal stop consent before chat adoption

* Use direct process-table test mock

* Fix chat adoption types and runtime localization

* Localize terminal takeover refusal guidance

* Keep CLI stop admission scoped to active source panes

* Preserve ordinary history resume through attach

* fix(native-chat): admit an unsaved transcript under a linked account folder

A missing transcript stayed unresolved while its account root resolved through
symlinks, so containment failed and a not-yet-saved conversation showed a load
error instead of the unsaved notice.

* Update adoption replay test for at-rest creation

* refactor(native-chat): move the newest-item import window into its own module

Keeps journal-legacy-import.ts under the line limit after the source
identity observer landed on top of the streaming import.

* fix(native-chat): restore the refusal error import lost in the main merge

* Remove duplicate account resolver and condense terminal admission

* Refuse conversation adoption until terminal writers exit

* Keep conversation inspection within lint limits

* Preserve conversation owner in launch feedback

* Expect unavailable writer scan to refuse fresh adoption

* Keep terminal conversation evidence tied to its execution

* Correct writer continuity test refusal and event shapes

* Prove original terminal writers exited before chat adoption

* Scope saved conversation holds to their execution host

* Keep child-inspection fixture verdict literal

* Recheck awake saved conversation holds against their owned pane

* fix: inspect owned Windows jobs independently of foreground naming

* test: use braces in Windows inspection fixture

* test: complete ordinary Windows terminal inspection fixture

* test: explain partial terminal inspection fixture

* fix: inspect the terminal a conversation is switched out of

* test: separate OpenCode writer observation coverage

* fix: tie terminal conversation links to the PTY execution

The conversations a terminal reported now live on its PTY record for that
incarnation, so moving the pane or running many other terminals can no longer
drop them, and a link whose terminal can't be inspected refuses.

- Each link keeps the agent process its hooks last proved; a live or
  suspended process refuses even when the terminal's tree is shell-only
  (detached tmux, nohup).
- tmux-published events are never pane-owned, and an unmarked binding copy no
  longer revokes an earlier pane-owned proof.
- A restored row or saved checkpoint is censused against the pane's current
  local PTY instead of requiring this runtime's observed identity.
- The refusal now says Orca can't confirm the agent has exited and asks the
  user to stop what's running there or close the terminal.

* test: drop the stub-host admission calls from OpenCode observation coverage

* test: census suspended children through the real terminal host

* test: drop writer checks the real-terminal flow already covers

* fix: let prompt workers outside the terminal's session pass the exit check

- The POSIX shell-alone proof skips a session leader below the shell and
  everything under it: a prompt's zpty worker has left the terminal, while
  suspended, background, nohup and disowned jobs still refuse.
- Windows steady-state polls read the cached process table; every read that
  decides adoption or close stays fresh.
- The pane header and tab menu point back to Session History, since their
  action is gone once the agent exits.

* fix(terminal): count only Windows job members still linked to the shell

A pane's job keeps every process it ever started, including daemons whose
parent exited (an rc-started ssh-agent), so such panes never read as an idle
shell and the chat switch refused forever. Walk descendants of the shell along
live parent links within the job, for every shell; the Git Bash launcher keeps
its bash-chain allowance and a child older than its parent still refuses.

* Revert "fix(terminal): count only Windows job members still linked to the shell"

MSYS exec and fork leave intermediate Win32 parents exited, so a live Claude
started through a script (and 'sleep 60 &') became unreachable and the chat
switch admitted while it ran. Back to the job census until a console-based
design is measured.

* fix(terminal): set aside detached daemons on a Windows shell-alone decision

A pane's job keeps every process ever started in it, so an rc-started
ssh-agent made a Git Bash pane read as busy forever and the chat switch
refused. MSYS fork/exec leaves live work under exited Win32 parents, so parent
links alone can't tell the daemon apart; it is the one that also left the
console. On a scanChildProcesses decision read (switch admission, run-terminal
close, close guards), a member neither linked to the shell nor attached to its
console no longer counts. Polls and shell confirmation keep the job census.
The console-list helper now forks through the process-host chokepoint.

* refactor(daemon): name the process inspection options once

Keeps the daemon router and terminal host within their line budget now that
the options carry scanChildProcesses.

* fix(terminal): set aside detached daemons only for adoption, never for close

Closing a Windows pane kills its whole job, so a close check that ignored a
detached process (gitk &, cmd /c start ...) would kill it without asking.
Adoption asks whether anything is still in the terminal; close asks whether
closing destroys something. Only the adoption read now sends ignoreDetached;
close guards and the run-terminal sweep keep the job census. The win32 test
also stops the ssh-agent it starts.

* fix(native-chat): check the terminal being switched out of before opening its chat

When the conversation already had a native chat, the switch hit the
ownership conflict first and the header opened that chat, even while the
terminal's agent (a later claude --resume) was still running. The switched-out
terminal is now checked before the conflict, so a live one refuses as usual
and one at rest still opens the owning chat.

* test(terminal): assert close reads never call an ssh-agent pane idle

A plain read can answer unverifiable while the eval's short-lived processes
exit; either way it must not read as no-children.

* fix(native-chat): check terminal writers before naming an owning chat for any switch

Session History's Resume in New Native Chat carries no source pane, so with an
earlier chat still holding the conversation it skipped the terminal check and
the owning chat's first send would start a second writer beside a live
terminal Claude. Every adoption request now runs the writer check before the
owner conflict, as the no-owner path already does.

* test(reliability): run the chat-switch writer oracles in the provider-ownership gate
2026-10-19 01:06:36 -07:00

137 lines
4.9 KiB
TypeScript

import type { ProcessTableRow } from './process-table-snapshot'
/**
* Correlation indexes over a process-table capture, generic over the row shape so the POSIX
* `ps` snapshot and the Windows process table share one pass instead of parallel ones.
*/
export type ProcessTableIndexStats = {
captures?: number
indexBuilds: number
rowVisits: number
indexLookups: number
}
/** The parent/child fields every process-table row shape shares. */
export type ProcessIdentityRow = { pid: number; ppid: number }
export type ProcessTableIndexOf<Row extends ProcessIdentityRow> = {
rows: readonly Row[]
byPid: ReadonlyMap<number, Row>
childrenByPpid: ReadonlyMap<number, readonly Row[]>
stats?: ProcessTableIndexStats
}
/** POSIX process-table index shape used by foreground-process resolvers. */
export type ProcessTableIndex = ProcessTableIndexOf<ProcessTableRow>
/**
* Build the correlation indexes in one linear pass over a capture. Only the
* indexes a resolver actually reads are materialized: group indexes would cost
* two more maps plus a per-row array allocation on every capture, and foreground
* membership is derived from each row's own `pgid` against the root's `tpgid`.
*
* Generic over the row shape so the Windows snapshot (`pid`/`ppid`/`name`/
* `command`) shares this pass rather than carrying a parallel one.
*/
export function buildProcessTableIndex<Row extends ProcessIdentityRow>(
rows: readonly Row[],
stats?: ProcessTableIndexStats
): ProcessTableIndexOf<Row> {
if (stats) {
stats.indexBuilds += 1
}
const byPid = new Map<number, Row>()
const childrenByPpid = new Map<number, Row[]>()
for (const row of rows) {
if (stats) {
stats.rowVisits += 1
}
// Preserve rows.find() semantics if a malformed table repeats a pid
if (!byPid.has(row.pid)) {
byPid.set(row.pid, row)
}
const children = childrenByPpid.get(row.ppid) ?? []
children.push(row)
childrenByPpid.set(row.ppid, children)
}
return { rows, byPid, childrenByPpid, stats }
}
/**
* Depth-first descendants of `rootPid`, deepest-last, off a prebuilt index.
*
* Each row is copied with its depth, so callers may not mutate the index's rows
* through the result. Ordering matches a per-call `childrenByPpid` walk exactly:
* children keep capture order and the stack pops last-pushed first.
*/
export function collectDescendantsFromIndex<Row extends ProcessIdentityRow>(
index: ProcessTableIndexOf<Row>,
rootPid: number,
/** Excludes a row and everything below it. */
prune?: (row: Row) => boolean
): (Row & { depth: number })[] {
const descendants: (Row & { depth: number })[] = []
// PID reuse can make a process snapshot cyclic; queue each PID at most once.
const visited = new Set<number>([rootPid])
const stack: { row: Row; depth: number }[] = []
const pushUnvisited = (row: Row, depth: number): void => {
if (visited.has(row.pid) || prune?.(row)) {
return
}
visited.add(row.pid)
stack.push({ row, depth })
}
for (const child of index.childrenByPpid.get(rootPid) ?? []) {
pushUnvisited(child, 1)
}
while (stack.length > 0) {
const { row, depth } = stack.pop()!
descendants.push({ ...row, depth })
for (const child of index.childrenByPpid.get(row.pid) ?? []) {
pushUnvisited(child, depth + 1)
}
}
return descendants
}
export function lookupProcessTableIndex<Row extends ProcessIdentityRow, T>(
index: ProcessTableIndexOf<Row>,
lookup: (index: ProcessTableIndexOf<Row>) => T,
stats = index.stats
): T {
if (stats) {
stats.indexLookups += 1
}
return lookup(index)
}
// Keyed by array identity, which also pins the row shape the entry was built
// for, so the one cast below cannot hand a caller another row type's index.
const processTableIndexes = new WeakMap<readonly ProcessIdentityRow[], unknown>()
/**
* Memoize one index per snapshot identity, so the panes that share a TTL-cached
* capture walk its rows once instead of once each. Keyed weakly by the rows
* array, so an index dies with the snapshot that produced it. The shared build
* materializes only `byPid` and `childrenByPpid`, so a one-pane relay pays for
* two maps per capture rather than four indexes no resolver queries.
*
* Deliberately stats-free: `buildProcessTableIndex` mutates the caller's counter
* bag and stores it on the index, so a shared index would hand one caller's bag
* to an unrelated later caller and let a cache hit satisfy an `indexBuilds`
* measurement without building anything. Measured callers keep calling
* `buildProcessTableIndex(rows, stats)` directly.
*/
export function getProcessTableIndex<Row extends ProcessIdentityRow>(
rows: readonly Row[]
): ProcessTableIndexOf<Row> {
const cached = processTableIndexes.get(rows) as ProcessTableIndexOf<Row> | undefined
if (cached) {
return cached
}
const index = buildProcessTableIndex(rows)
processTableIndexes.set(rows, index)
return index
}