mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
* docs(exit-cause): pin why isProvenProcessExit(0) must stay true
isProvenProcessExit asks whether the process ended; the cause resolvers ask
why. Their disagreement on 0 is the design, not a defect: login(1) wraps
every macOS local PTY once the TCC preflight passes, so routing
hostReportsChildExitStatus through the predicate would leave every pane a
user closed with `exit` mounted forever.
No behavior change; comment and regression tests only.
* fix(automations): report an unverifiable process loss as lost, not failed
Two automation readers consumed the raw PTY exit code with no liveness
check, so the -1 unverified sentinel — on SSH, a live relay whose reattach
failed — was published as status 'dispatch_failed' with "Automation process
exited with code -1." The run was asserted finished when all that happened
was that we lost contact.
Route both through the existing vocabulary:
- The completion tracker records no result for an unproven code. The run
keeps its non-final 'dispatched' status, so it is never evicted and never
shown as Failed, and stays owned by main's AutomationRunCompletionWatcher,
which already reports a genuinely unobservable run truthfully ("lost the
terminal for this run") rather than inventing an exit code. finalize() is
never reached, so a terminal whose process cannot be proven dead is never
closed. A later done can still complete the run.
- Both runtime `terminal.wait` readers defaulted an absent status to 0,
minting a clean finish out of no evidence. They now share
runtimeWaitExitCode, which defaults to the new UNVERIFIED_PROCESS_EXIT_CODE.
- The background-session exit handler no longer clears the tab-PTY binding
on an unverified loss, matching pty-exit-hibernate.ts, and marks the tab
so orphan cleanup cannot sweep an agent that may still be running.
A proven exit is unchanged: 0 still completes and finalizes, and a real
nonzero failure still reports dispatch_failed.
142 lines
6.1 KiB
TypeScript
142 lines
6.1 KiB
TypeScript
/**
|
|
* Why a terminal's process is gone.
|
|
*
|
|
* Orca used to record one number and let every reader guess. That number is not
|
|
* evidence: the stop paths synthesize it, node-pty reports 0 for a signalled
|
|
* death, and macOS's TCC `login(1)` wrapper returns its own status instead of
|
|
* the shell's. A clean finish, an OOM kill and an operator close all arrived as
|
|
* "code 0" (STA-4536, STA-4603).
|
|
*
|
|
* So a cause is only ever built from evidence someone actually holds, and the
|
|
* absence of evidence is spelled `unknown` rather than guessed.
|
|
*/
|
|
export type TerminalExitCause =
|
|
/** Teardown was requested through Orca — a close, a stop, a worktree removal. Not a failure. */
|
|
| { kind: 'operator_close' }
|
|
/** The host reported a signal. The agent did not choose to stop. */
|
|
| { kind: 'signaled'; signal: number }
|
|
/** The process ended on its own and the host vouched for the status. */
|
|
| { kind: 'exited'; exitCode: number }
|
|
/** Nothing here is provable. Never narrow this by guessing. */
|
|
| { kind: 'unknown'; reason: TerminalExitUnknownReason }
|
|
|
|
export type TerminalExitUnknownReason =
|
|
/** A stop was issued and no exit was ever observed, so the process may still be alive. */
|
|
| 'stop_unverified'
|
|
/** The host cannot report its child's status at all (see {@link hostReportsChildExitStatus}). */
|
|
| 'host_status_unavailable'
|
|
/**
|
|
* The exit arrived from a source that reports a code but no cause — an SSH
|
|
* relay, or a daemon older than this field. Its `0` may be a clean finish or
|
|
* a signal; nothing here can tell, so nothing here claims to.
|
|
*/
|
|
| 'cause_unreported'
|
|
|
|
export const OPERATOR_CLOSE_EXIT_CAUSE: TerminalExitCause = { kind: 'operator_close' }
|
|
|
|
/**
|
|
* The code every surface uses for "contact was lost before the host could vouch
|
|
* for this process". `resolveProcessExitCause` reads it as `stop_unverified`
|
|
* and {@link isProvenProcessExit} rejects it.
|
|
*
|
|
* A reader handed an *optional* status by a host must default to this, never to
|
|
* `0`: `exitCode ?? 0` mints a clean finish out of an absence of evidence.
|
|
*/
|
|
export const UNVERIFIED_PROCESS_EXIT_CODE = -1
|
|
|
|
/**
|
|
* Build a cause from what the host actually observed.
|
|
*
|
|
* `hostReportsChildExitStatus: false` means the number and signal below describe
|
|
* a wrapper process, not the agent — so they are dropped rather than reported.
|
|
*/
|
|
export function resolveProcessExitCause(observation: {
|
|
exitCode: number
|
|
signal?: number | null
|
|
hostReportsChildExitStatus?: boolean
|
|
}): TerminalExitCause {
|
|
if (observation.hostReportsChildExitStatus === false) {
|
|
return { kind: 'unknown', reason: 'host_status_unavailable' }
|
|
}
|
|
if (typeof observation.signal === 'number' && observation.signal > 0) {
|
|
return { kind: 'signaled', signal: observation.signal }
|
|
}
|
|
// Why: the stop paths pass a negative code to mean "we asked it to stop and
|
|
// never saw it die". That is an absence of evidence, not an exit status.
|
|
if (observation.exitCode < 0) {
|
|
return { kind: 'unknown', reason: 'stop_unverified' }
|
|
}
|
|
return { kind: 'exited', exitCode: observation.exitCode }
|
|
}
|
|
|
|
/**
|
|
* The cause for an exit delivered without one — an older daemon, or the SSH
|
|
* relay, which forwards a code and nothing else.
|
|
*
|
|
* Zero is the ambiguous one, and the only one worth refusing: node-pty pairs it
|
|
* with every signalled death, and a wrapper spawn returns it for any outcome at
|
|
* all. A nonzero status is never fabricated that way, so it is still reported.
|
|
*/
|
|
export function resolveUnreportedExitCause(exitCode: number): TerminalExitCause {
|
|
if (exitCode < 0) {
|
|
return { kind: 'unknown', reason: 'stop_unverified' }
|
|
}
|
|
return exitCode === 0
|
|
? { kind: 'unknown', reason: 'cause_unreported' }
|
|
: { kind: 'exited', exitCode }
|
|
}
|
|
|
|
/** One line an operator or a coordinating agent can read without decoding a number. */
|
|
export function describeTerminalExitCause(cause: TerminalExitCause): string {
|
|
switch (cause.kind) {
|
|
case 'operator_close':
|
|
return 'Terminal closed by operator request'
|
|
case 'signaled':
|
|
return `Agent process killed by signal ${cause.signal}`
|
|
case 'exited':
|
|
return `Agent process exited with code ${cause.exitCode}`
|
|
case 'unknown':
|
|
switch (cause.reason) {
|
|
case 'stop_unverified':
|
|
return 'Agent process stop was requested but never confirmed'
|
|
case 'host_status_unavailable':
|
|
return 'Agent process ended; this host cannot report why'
|
|
case 'cause_unreported':
|
|
return 'Agent process ended; the reporting host did not say why'
|
|
}
|
|
}
|
|
}
|
|
|
|
/** True when a dispatch that ended this way was torn down deliberately rather than lost. */
|
|
export function isDeliberateTerminalExit(cause: TerminalExitCause): boolean {
|
|
return cause.kind === 'operator_close'
|
|
}
|
|
|
|
/**
|
|
* Whether an exit code is positive evidence that the process ended.
|
|
*
|
|
* Negative codes are synthetic stop sentinels; they mean that the host lost
|
|
* contact before it could vouch for the child, so downstream cleanup must use
|
|
* the `unverifiable` path instead of treating the tab as exited.
|
|
*
|
|
* This asks *whether* the process ended; the cause resolvers above ask *why*.
|
|
* The two deliberately disagree about `0`, and that is not a defect:
|
|
*
|
|
* - `resolveUnreportedExitCause(0)` is `unknown` because a bare zero cannot
|
|
* distinguish a clean finish from a signal — an unknowable **reason**.
|
|
* - `isProvenProcessExit(0)` is `true` because a host only forwards a
|
|
* non-negative code after its provider observed the process end — a known
|
|
* **fact of death**, whatever the reason.
|
|
*
|
|
* Do not route `hostReportsChildExitStatus` or `signal` through here to
|
|
* "narrow" the zero. `login(1)` wraps every macOS local PTY once the TCC
|
|
* preflight passes, so `hostReportsChildExitStatus` is false for essentially
|
|
* all of them (macos-tcc-login-shell.ts) — yet login forks the shell and waits,
|
|
* so its own exit *is* evidence the shell died. Treating those as unproven
|
|
* would strand every macOS pane that a user closed with `exit`, which is the
|
|
* mirror-image regression of reporting a lost host as exited.
|
|
*/
|
|
export function isProvenProcessExit(exitCode: number): boolean {
|
|
return resolveProcessExitCause({ exitCode }).kind !== 'unknown'
|
|
}
|