mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 16:02:42 +00:00
Agent status can hold `working` on a pane where no work is outstanding, and nothing closes the gap. A pane's Claude state is a join of a lead turn and three latches — the subagent roster, the background-task gate and the session-cron gate — and each is set by a hook and cleared only by another hook. Claude Code emits no terminating hook on `/exit`, `/clear`, Ctrl+C, crash, SIGKILL or terminal close, so every one of those latches is a claim with no owner and no expiry. The join is also materialised at ingest time and persisted, so a stale `working` survives restart and blocks hibernation, which requires `done`. Registering `SessionEnd` is not the fix: it covers roughly a third of exit paths (measured on 2.1.231/2.1.233; upstream anthropics/claude-code#17885 and #6428 are both closed as not planned). Nor is a TTL — `AGENT_STATUS_STALE_AFTER_MS` only decays the sidebar dot at read time while the stored row stays non-terminal. So the backstop is built from evidence Orca already owns. A session id that changes means the conversation was replaced. On the first hook of the new session — whatever that hook is — the previous session's own claims are void: its session crons and its one-shot subagents. Deliberately not voided: the background-task gate (a background shell is an OS process that survives `/clear`, and the previous inventory is positive evidence it was running), and `confirmedTeammate` rows (persistent in-process teammates a lead swap cannot end). The lead record is left to the incoming event's own fold. A certified process exit retires the pane. Orca already does this on every attributable PTY exit — `clearProviderPtyState` resolves the pane key and calls `clearPaneState` — but that resolution depends on the spawn-time `ptyPaneKey` mapping, which a restored or reattached PTY may never rebuild. Those panes keep their row and latches for good. `onPtyExit` knows the keys teardown could not resolve, so it reconciles them from its own records. The certificate is `exitCode >= 0 || hostExitConfirmed || providerExitObserved`: a synthetic `-1` from a failed stop is not a death (the PTY can have survived it), while a real exit can also report `-1`, so neither the code nor the SSH surface predicate is sufficient alone. `providerExitObserved` is additive and separate from `hostExitConfirmed`, which also drives the liveness verdict and the SSH surface decision. A confirmed shell foreground is the `/exit` case: the agent died, the shell lived. That already dropped the row, but through `agentStatus:drop`, which by its own contract preserves a live pane's caches — so every latch survived and the next event resolved the pane back to `working`. It now routes through the reconciler instead, gated on a per-pane accepted-status generation rather than row identity: the confirming process read can take seconds, and `updatedAt` cannot order two writes inside one millisecond (the store deliberately admits equal timestamps). Cold start generalises the same way. The startup sweep required a restored subagent roster, so a stranded lead row, background-task gate or cron gate — the shapes with no child event left to reap them — were never candidates. Hibernation needs no change: with the above, those rows become genuinely `done` and the lockout resolves through the front door. A `restoredUnconfirmed` bypass in the planner would let it reclaim the heap of an agent that may be working. Not included: folding `background_tasks` from a child-attributed `SubagentStop`. Writing its test surfaced #11838's deliberate assertion that child inventories are not authoritative for lead-owned background work, and the listener says the same — "background_tasks is trusted only where unambiguous". An empty list on a `SubagentStop` does not prove the lead's shell ended, so the fold would have cleared a gate on evidence that establishes nothing. STA-4119's live-side question — whether a genuinely live background shell should hold the lead row after the lead turn ends — is untouched. This change extends gate-clearing to zero new triggers.
58 lines
3.0 KiB
TypeScript
58 lines
3.0 KiB
TypeScript
import type {
|
|
AgentStatusClearIpcPayload,
|
|
AgentStatusIpcPayload,
|
|
MigrationUnsupportedPtyEntry
|
|
} from '../../shared/agent-status-types'
|
|
import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent'
|
|
import type { AgentQuestionAnsweredInferenceRequest } from '../../shared/agent-question-answered-intent'
|
|
import type { ComputerAwakeStatus } from '../../shared/computer-awake-mode'
|
|
|
|
export type AgentStatusApi = {
|
|
/** Listen for agent status updates forwarded from native hook receivers. */
|
|
onSet: (callback: (data: AgentStatusIpcPayload) => void) => () => void
|
|
/** Listen for main-process cleanup that evicted cached hook status. */
|
|
onClear: (callback: (data: AgentStatusClearIpcPayload) => void) => () => void
|
|
/** Return the current main-process hook cache after renderer hydration. */
|
|
getSnapshot: () => Promise<AgentStatusIpcPayload[]>
|
|
inferInterrupt: (request: AgentInterruptInferenceRequest) => Promise<boolean>
|
|
/** Guarded clear for an answered AskUserQuestion wait — the CLI emits no hook at answer time, so the renderer reports the submit keystroke. */
|
|
inferQuestionAnswered: (request: AgentQuestionAnsweredInferenceRequest) => Promise<boolean>
|
|
/** Listen for PTYs on a legacy numeric pane key that have registry-backed UUID pane proof. */
|
|
onMigrationUnsupported: (callback: (entry: MigrationUnsupportedPtyEntry) => void) => () => void
|
|
onMigrationUnsupportedClear: (callback: (data: { ptyId: string }) => void) => () => void
|
|
onLegacyWorkerTerminalRecovery: (
|
|
callback: (data: {
|
|
paneKey: string
|
|
resolution: 'adopted' | 'exited' | 'rolled_back'
|
|
ptyId?: string
|
|
}) => void
|
|
) => () => void
|
|
getMigrationUnsupportedSnapshot: () => Promise<MigrationUnsupportedPtyEntry[]>
|
|
/** Drop a paneKey from the main-process hook cache and on-disk last-status file. Fire-and-forget. */
|
|
drop: (paneKey: string) => void
|
|
/** Retire a pane whose agent process is proven gone — clears the row AND the per-pane caches a
|
|
* dismissal deliberately keeps. Not `drop`: that one is a user dismissal of a live pane's row. */
|
|
reconcileEndedProcess: (paneKey: string) => void
|
|
/** Drop every cached hook status under one terminal tab prefix. Fire-and-forget. */
|
|
dropByTabPrefix: (tabId: string) => void
|
|
/** Permanently retire one pane's hook authority while siblings stay live. */
|
|
retirePaneAuthority: (paneKey: string) => void
|
|
/** Lift one pane's retirement fence when a live PTY re-attaches to it. Closed tabs stay retired. */
|
|
restorePaneAuthority: (paneKey: string) => void
|
|
/** Move hook authority when a live pane is detached into another tab. */
|
|
transferPaneAuthority: (args: { fromPaneKey: string; toPaneKey: string; ptyId?: string }) => void
|
|
}
|
|
|
|
export type AgentTrustApi = {
|
|
markTrusted: (args: {
|
|
preset: 'cursor' | 'copilot' | 'codex'
|
|
workspacePath: string
|
|
connectionId?: string
|
|
}) => Promise<void>
|
|
}
|
|
|
|
export type AgentAwakeApi = {
|
|
getStatus: () => Promise<ComputerAwakeStatus>
|
|
onChanged: (callback: (status: ComputerAwakeStatus) => void) => () => void
|
|
}
|