mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 00:03:37 +00:00
fix(terminal): truthful handle liveness + no forked resume tabs for hidden restorable panes (#12574)
* fix(runtime): report terminal handles disconnected on controller-proven PTY absence leaf.connected mirrors the renderer graph (ptyId !== null), so a restored surface whose PTY died with a prior process was listed connected/writable forever with empty title/lastOutputAt/preview — the exact signature automation saw on run6 workspaces after a restart. listTerminals now threads the controller inventory it already fetches into buildTerminalSummary and demotes only on proven absence, only for locally-scoped ids; unknown liveness and SSH/remote scopes never demote, and no session or pane is retired. * fix(terminal): stop forking hidden restorable panes into replacement resume tabs paneWillConnectOnActivation still assumed the pre-keep-alive mount model, but every non-parked tab of the active worktree mounts and connects hidden at 0x0. Activation therefore appended a replacement resume tab per non-group-active agent pane and handed it the sleeping record, stranding the hidden pane as a bare shell — or forking two live surfaces onto one provider session when the old PTY survived in the daemon. The predicate now answers "will mount and connect": any non-web-mirror tab of the active worktree qualifies; non-active worktrees still answer false so background wake keeps its append-based resume. Contract change: reverses the hidden-tab expectation from #6800, whose premise (hidden panes never connect) no longer holds; that test is updated in place. * test(terminal): pin the remote-scope exemption and the web-mirror ownership exception CodeRabbit flagged both exclusions as untested: a remote-runtime-scoped leaf absent from the local inventory must stay connected (its inventory lives on the remote host), and a web-mirror tab must not own sleeping-session recovery (it never mounts a local pane), so the appended replacement remains its correct resume path. * fix(terminal): rescue just-spawned ptys from absence demotion; unpark panes owning sleeping records Review (GPT verifier) confirmed two gaps: - listTerminals demoted a live just-spawned PTY when listProcesses snapshotted before session registration (the sweep's hasPty rescue is leaf-gated), and federation reads one connected:false as exited. The summary's proven-absence check now also consults the provider's sync hasPty. - Ordinary per-tab cold parking (30s hidden) kept a non-group-active pane unmounted, so a sleeping record it owns under the new ownership predicate could not cold-restore until the user revealed the tab. Per-tab parks now exempt panes owning a sleeping-session record; worktree-level parks are untouched (they clear on activation). * fix(terminal): reconcile the daemon session cache on inventory; scope the park exemption to consumable records Round-2 review confirmed two holes in the round-1 fixes: - DaemonPtyAdapter.hasPty is cached activeSessionIds membership, and a successful listSessions never removed ids the authoritative inventory omitted — an exit missed while the socket was down kept hasPty true forever, and the new spawn/list-race rescue would trust it, reopening connected-forever for that pty. listProcesses now drops pre-request cached ids the inventory does not list alive (ids spawned mid-flight are snapshot- protected). - The park exemption covered records a pane can never consume (automaticResumeBlockedBy, passive-completed evidence), pinning hidden panes mounted indefinitely. The exemption now lives in sleeping-record-park-exemption.ts and requires a consumable record. Also pins the web-mirror replacement's resume claim and startup command (CodeRabbit round-2). --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -1657,6 +1657,21 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
})
|
||||
|
||||
describe('listProcesses', () => {
|
||||
// Why: hasPty reads the activeSessionIds cache; an exit missed while the
|
||||
// socket was down must not survive an authoritative inventory, or absence
|
||||
// proofs (terminal list demotion, send guard) are defeated forever.
|
||||
it('drops cached session ids an authoritative inventory omits', async () => {
|
||||
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
const staleId = 'repo::/repo/stale@@deadbeef'
|
||||
;(adapter as unknown as { activeSessionIds: Set<string> }).activeSessionIds.add(staleId)
|
||||
expect(adapter.hasPty(staleId)).toBe(true)
|
||||
|
||||
await adapter.listProcesses()
|
||||
|
||||
expect(adapter.hasPty(staleId)).toBe(false)
|
||||
expect(adapter.hasPty(id)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns active sessions', async () => {
|
||||
await adapter.spawn({
|
||||
cols: 80,
|
||||
|
||||
@@ -1382,6 +1382,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
|
||||
async listProcesses(opts?: { deadlineMs?: number }): Promise<PtyProcessInfo[]> {
|
||||
// Why: snapshotted before the request so ids spawned mid-flight can never
|
||||
// be reconciled away below.
|
||||
const preRequestActiveIds = new Set(this.activeSessionIds)
|
||||
try {
|
||||
// Why: connect + listSessions share the caller's one absolute deadline so a
|
||||
// wedged handshake cannot burn the whole teardown budget before the list issues.
|
||||
@@ -1393,10 +1396,12 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
)
|
||||
const admission = new PtyProcessListAdmission()
|
||||
const processes: PtyProcessInfo[] = []
|
||||
const aliveSessionIds = new Set<string>()
|
||||
for (const session of result.sessions) {
|
||||
if (!session.isAlive) {
|
||||
continue
|
||||
}
|
||||
aliveSessionIds.add(session.sessionId)
|
||||
const { worktreeId } = parsePtySessionId(session.sessionId)
|
||||
processes.push(
|
||||
admission.admit({
|
||||
@@ -1412,6 +1417,14 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
})
|
||||
)
|
||||
}
|
||||
// Why: hasPty reads activeSessionIds, and an exit missed while the socket
|
||||
// was disconnected otherwise survives an authoritative inventory forever —
|
||||
// defeating every absence proof built on the cache.
|
||||
for (const id of preRequestActiveIds) {
|
||||
if (!aliveSessionIds.has(id)) {
|
||||
this.activeSessionIds.delete(id)
|
||||
}
|
||||
}
|
||||
this.publishAuditObservation(
|
||||
recordAuthenticatedInventory(this.auditContext, this.exactDaemonIncarnation)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user