fix(agents): lift the pane retirement fence when a live PTY re-attaches (STA-4114) (#14624)

* fix(agents): lift the pane retirement fence when a live PTY re-attaches (STA-4114)

A detach/reattach cycle retires the pane on both sides — the main hook
server's closedAgentStatusPaneKeys and the renderer's
recentlyRetiredAgentStatusPaneKeys — and nothing ever cleared either one.
The pane then rejected every later working/done event for the rest of its
life while Pi kept running normally in the same PTY.

Bind the fence to the fact it asserts: retirement claims the pane is gone,
and binding a live PTY to that exact pane disproves it. Clear both
tombstones at the spawn/attach chokepoint and at the daemon-backed reattach
path, so recovery does not depend on the agent starting another turn — a
pane re-attached mid-turn only has agent_end left to report, and one
re-attached while idle emits nothing at all. Closed-tab tombstones are a
separate, stronger claim and are deliberately left standing.

* test(agent-hooks): re-arm the idle re-attach test against a turn-boundary fix

The idle re-attach assertion posted only before_agent_start, which #14626
turns into a fence-lifting turn boundary. Under that change the test passes
whether or not restorePaneAuthority runs, so it stops pinning this PR's
mechanism. Assert first on agent_end — a non-turn event — so the test proves
the fence was already down when the hook arrived.

Verified: with restorePaneAuthority neutered AND before_agent_start added to
the restart predicate, the old assertion passes and the new one fails.

* fix(agents): lift a retired pane's whole fence, aliases included (STA-4114)

Retirement fences the pane, its resolved owner, and every alias of it, then
deletes those aliases. Restoring only the key handed to us left the rest
standing — and a detached pane's process keeps posting the key it launched
under (server.ts:1614), so the canonical re-attach case stayed suppressed
with the fence apparently lifted. Verified against the real omp binary: the
row came back under the stale launch pane instead of the detached owner.

Record what each retirement fenced and replay it as a unit, rebuilding the
aliases it deleted. Keys and aliases belonging to a closed tab are skipped,
so the stronger claim survives and a live process is never routed back into
a closed tab. The record is indexed by every fenced key and bounded at 1024
like the maps it mirrors; an evicted record degrades to the old behaviour.

Also records why the renderer's restore IPC is deliberately unguarded: that
map is not a mirror of main's (retirePtyAgentLaunchAuthority fences main
directly on command-finished and PTY exit, and nothing pushes it back), and
it is per-window and non-persisted, so gating the send on a local tombstone
reintroduces this bug for exactly those panes.
This commit is contained in:
Brennan Benson
2026-08-17 15:35:36 -07:00
committed by GitHub
parent c303d36228
commit 1a04d292b6
12 changed files with 591 additions and 0 deletions
@@ -267,6 +267,365 @@ describe('AgentHookServer listener replay', () => {
}
})
// STA-4114: a detach/reattach cycle retires the pane, and nothing lifted the fence.
// Reviving only on a new turn cannot help a pane re-attached mid-turn (its remaining
// events are agent_end, not a new-turn event) or one re-attached idle.
for (const kind of ['pi', 'omp', 'prime-agent'] as const) {
it(`re-attaching a retired ${kind} pane restores status without needing a new turn`, async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postHook = (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/${kind}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload, { launchToken: `retired-${kind}-token` }))
})
await postHook({ hook_event_name: 'before_agent_start', prompt: 'turn in flight' })
server.retirePaneAuthority(PANE)
// The turn was already running, so only its completion is left to report —
// and while retired it is suppressed. This is the reported permanent failure.
await postHook({ hook_event_name: 'agent_end' })
expect(server.getStatusSnapshot()).toEqual([])
expect(server.restorePaneAuthority(PANE)).toBe(true)
await postHook({ hook_event_name: 'agent_end' })
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: PANE, state: 'done' })
])
} finally {
server.stop()
}
})
}
it('re-attaching a retired pane while idle re-opens it for a much later first turn', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postHook = (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload, { launchToken: 'idle-reattach-token' }))
})
// Nothing in flight: the pane is retired and re-attached while the agent sits idle.
server.retirePaneAuthority(PANE)
expect(server.restorePaneAuthority(PANE)).toBe(true)
// Why: prove the fence is already down before any turn event arrives. Asserting
// only on before_agent_start would also pass if a turn boundary lifted the fence,
// so it cannot distinguish re-attach revival from turn-triggered revival (#14626).
await postHook({ hook_event_name: 'agent_end' })
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: PANE, state: 'done' })
])
await postHook({ hook_event_name: 'before_agent_start', prompt: 'much later turn' })
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: PANE, state: 'working', prompt: 'much later turn' })
])
} finally {
server.stop()
}
})
it('re-attach does not lift a closed-tab tombstone', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postHook = (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload, { launchToken: 'closed-tab-token' }))
})
// Pane fence AND tab fence are both standing; re-attach may lift neither.
server.retirePaneAuthority(PANE)
server.dropStatusEntriesByTabPrefix('tab-1')
expect(server.restorePaneAuthority(PANE)).toBe(false)
await postHook({ hook_event_name: 'before_agent_start', prompt: 'after tab close' })
expect(server.getStatusSnapshot()).toEqual([])
// The pane fence must still be standing too, not silently lifted underneath.
expect(server.restorePaneAuthority(PANE)).toBe(false)
} finally {
server.stop()
}
})
// STA-4114: retirement fences every alias of a pane and deletes the alias itself.
// Restoring only the owner key leaves the physical key the live process still posts
// fenced forever — the detached pane, which is the canonical re-attach case.
it('re-attaching a detached pane accepts hooks on the pane key its process launched under', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const detachedPane = makePaneKey('tab-2', LEAF_2)
const env = server.buildPtyEnv()
const postHook = (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload, { launchToken: 'detached-token' }))
})
await postHook({ hook_event_name: 'before_agent_start', prompt: 'turn in flight' })
// Detach into another tab. The live process keeps posting PANE (server.ts:1614),
// so the alias is the only thing routing it to its new owner.
server.transferPaneAuthority(PANE, detachedPane, 'pty-detached')
server.retirePaneAuthority(detachedPane)
expect(server.restorePaneAuthority(detachedPane)).toBe(true)
await postHook({ hook_event_name: 'agent_end' })
// One row under the OWNER key. Lifting the fence without rebuilding the alias
// mints a second row on the stale key instead.
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: detachedPane, state: 'done' })
])
} finally {
server.stop()
}
})
it('re-attaching restores a legacy numeric pane key alias', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const legacyPane = 'tab-1:0'
const env = server.buildPtyEnv()
const postHook = (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(
buildBody(payload, { paneKey: legacyPane, launchToken: 'legacy-token' })
)
})
server.registerPaneKeyAlias(legacyPane, PANE, 'pty-legacy')
await postHook({ hook_event_name: 'before_agent_start', prompt: 'legacy turn' })
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: PANE, state: 'working' })
])
server.retirePaneAuthority(PANE)
expect(server.restorePaneAuthority(PANE)).toBe(true)
await postHook({ hook_event_name: 'agent_end' })
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: PANE, state: 'done' })
])
} finally {
server.stop()
}
})
it('does not rebuild a detached pane alias into a closed tab', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const detachedPane = makePaneKey('tab-2', LEAF_2)
const env = server.buildPtyEnv()
const postHook = (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload, { launchToken: 'detached-closed-token' }))
})
await postHook({ hook_event_name: 'before_agent_start', prompt: 'turn in flight' })
server.transferPaneAuthority(PANE, detachedPane, 'pty-detached')
server.retirePaneAuthority(detachedPane)
// The tab the pane was detached into is closed: the stronger claim wins, and the
// alias must not be resurrected to route a live process into a closed tab.
server.dropStatusEntriesByTabPrefix('tab-2')
expect(server.restorePaneAuthority(detachedPane)).toBe(false)
await postHook({ hook_event_name: 'agent_end' })
expect(server.getStatusSnapshot()).toEqual([])
} finally {
server.stop()
}
})
// Why: the guard above short-circuits on the closed owner, so it never reaches the
// alias rebuild. Restoring the ORIGINAL key does reach it — and rebuilding the alias
// there would route a live process into the closed tab and silence it again.
it('re-opens the original pane instead of rebuilding an alias into a closed tab', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const detachedPane = makePaneKey('tab-2', LEAF_2)
const env = server.buildPtyEnv()
const postHook = (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload, { launchToken: 'reopen-origin-token' }))
})
await postHook({ hook_event_name: 'before_agent_start', prompt: 'turn in flight' })
server.transferPaneAuthority(PANE, detachedPane, 'pty-detached')
server.retirePaneAuthority(detachedPane)
server.dropStatusEntriesByTabPrefix('tab-2')
// The pane the process actually lives in is tab-1, which is still open.
expect(server.restorePaneAuthority(PANE)).toBe(true)
await postHook({ hook_event_name: 'agent_end' })
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: PANE, state: 'done' })
])
} finally {
server.stop()
}
})
// Why: closedAgentStatusTabIds is LRU-bounded, so the tab fence is not permanent.
// If restoring a sibling key lifted the closed tab's PANE fence too, eviction of the
// tab id would leave nothing at all holding that pane shut.
it('leaves a closed tab pane fenced once its tab id is evicted', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const detachedPane = makePaneKey('tab-2', LEAF_2)
const env = server.buildPtyEnv()
const postHook = (
payload: Record<string, unknown>,
overrides: Record<string, unknown> = {}
): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload, { launchToken: 'evict-token', ...overrides }))
})
await postHook({ hook_event_name: 'before_agent_start', prompt: 'turn in flight' })
server.transferPaneAuthority(PANE, detachedPane, 'pty-detached')
server.retirePaneAuthority(detachedPane)
server.dropStatusEntriesByTabPrefix('tab-2')
expect(server.restorePaneAuthority(PANE)).toBe(true)
for (let i = 0; i <= CLOSED_AGENT_STATUS_TAB_IDS_MAX; i += 1) {
server.dropStatusEntriesByTabPrefix(`tab-evict-${i}`)
}
await postHook(
{ hook_event_name: 'before_agent_start', prompt: 'after eviction' },
{ paneKey: detachedPane, tabId: 'tab-2' }
)
expect(server.getStatusSnapshot()).toEqual([])
} finally {
server.stop()
}
})
// Why: a detach re-points a legacy numeric alias at an owner in another tab, so the
// fence can hold keys from two tabs at once. A legacy key never parses as a stable
// one, so a stable-only tab check would wave it through when its own tab is closed.
it('keeps a legacy alias fenced when its own tab closed but the owner tab did not', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const legacyPane = 'tab-1:0'
const detachedPane = makePaneKey('tab-2', LEAF_2)
const env = server.buildPtyEnv()
const postHook = (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(
buildBody(payload, { paneKey: legacyPane, launchToken: 'legacy-cross-tab-token' })
)
})
server.registerPaneKeyAlias(legacyPane, PANE, 'pty-cross')
server.transferPaneAuthority(PANE, detachedPane, 'pty-cross')
server.retirePaneAuthority(detachedPane)
server.dropStatusEntriesByTabPrefix('tab-1')
// The owner tab is open, so the restore proceeds — but the legacy key's own tab
// is closed, and its alias must not be rebuilt into the still-open owner.
expect(server.restorePaneAuthority(detachedPane)).toBe(true)
await postHook({ hook_event_name: 'before_agent_start', prompt: 'after tab-1 close' })
expect(server.getStatusSnapshot()).toEqual([])
} finally {
server.stop()
}
})
it('does not clobber a newer alias when replaying a retired fence', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const legacyPane = 'tab-1:0'
const reboundPane = makePaneKey('tab-1', LEAF_3)
const env = server.buildPtyEnv()
const postHook = (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(
buildBody(payload, { paneKey: legacyPane, launchToken: 'rebind-token' })
)
})
server.registerPaneKeyAlias(legacyPane, PANE, 'pty-old')
server.retirePaneAuthority(PANE)
// The pane rebound to a different owner before the restore landed.
server.registerPaneKeyAlias(legacyPane, reboundPane, 'pty-new')
server.restorePaneAuthority(PANE)
await postHook({ hook_event_name: 'before_agent_start', prompt: 'after rebind' })
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: reboundPane, state: 'working' })
])
} finally {
server.stop()
}
})
it('accepts a resumed-session SessionStart after launch authority retires in a reusable pane', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
+100
View File
@@ -164,6 +164,12 @@ type PaneKeyAliasEntry = {
updatedAt: number
authorityVerified: boolean
}
type RetiredPaneAlias = { physicalPaneKey: string; entry: PaneKeyAliasEntry }
/** What one retirement fenced, so a re-attach can lift exactly that set and no more. */
type RetiredPaneFence = {
paneKeys: readonly string[]
aliases: readonly RetiredPaneAlias[]
}
// Why: co-located with the endpoint file in userData/agent-hooks/ so hook-server cross-restart artifacts stay together.
const LAST_STATUS_FILE_NAME = 'last-status.json'
@@ -187,6 +193,7 @@ const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024
export const CLOSED_AGENT_STATUS_PANE_KEYS_MAX = 1024
export const PANE_KEY_ALIASES_MAX = 1024
export const RETIRED_PANE_FENCES_MAX = 1024
type LastStatusFile = {
version: number
@@ -702,6 +709,10 @@ export class AgentHookServer {
private revokedHydratedAuthorityCommitments = new WeakSet<AgentHookAuthorityEvidence>()
private currentAuthorityObservations = new Map<string, AgentHookAuthorityEvidence>()
private legacyPaneKeyAliases = new Map<string, PaneKeyAliasEntry>()
// Why: indexed by every key the retirement fenced, so a re-attach on any of them
// (owner, physical, or a deleted alias) finds the same record. Bounded like the maps
// it mirrors; an evicted record simply degrades to lifting the key it was handed.
private retiredPaneFencesByKey = new Map<string, RetiredPaneFence>()
private paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null
// Why: on-disk last-status cache path; null without a userDataPath (tests), where persistence is a no-op and only in-memory replay applies.
private lastStatusFilePath: string | null = null
@@ -1118,6 +1129,33 @@ export class AgentHookServer {
return 'suppress'
}
// Why: a fence can span tabs (a pane detached into another tab), and legacy numeric
// keys never parse as stable ones — resolve both forms so neither slips the tab check.
private isClosedAgentStatusTabForPaneKey(paneKey: string): boolean {
const tabId =
parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? undefined
return tabId !== undefined && this.closedAgentStatusTabIds.has(tabId)
}
private recordRetiredPaneFence(
paneKeys: ReadonlySet<string>,
aliases: readonly RetiredPaneAlias[]
): void {
const fence: RetiredPaneFence = { paneKeys: [...paneKeys], aliases }
for (const key of paneKeys) {
// Delete-then-set keeps the newest fence most-recent so eviction sheds only the oldest.
this.retiredPaneFencesByKey.delete(key)
this.retiredPaneFencesByKey.set(key, fence)
}
while (this.retiredPaneFencesByKey.size > RETIRED_PANE_FENCES_MAX) {
const oldest = this.retiredPaneFencesByKey.keys().next().value
if (oldest === undefined) {
break
}
this.retiredPaneFencesByKey.delete(oldest)
}
}
private markPaneClosedForAgentStatus(paneKey: string): void {
this.closedAgentStatusPaneKeys.delete(paneKey)
this.closedAgentStatusPaneKeys.add(paneKey)
@@ -1755,15 +1793,18 @@ export class AgentHookServer {
retirePaneAuthority(paneKey: string): void {
const ownerPaneKey = this.resolvePaneKeyAlias(paneKey)
const paneKeys = new Set([paneKey, ownerPaneKey])
const retiredAliases: RetiredPaneAlias[] = []
let aliasChanged = false
for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) {
if (physicalPaneKey === paneKey || entry.stablePaneKey === ownerPaneKey) {
this.legacyPaneKeyAliases.delete(physicalPaneKey)
retiredAliases.push({ physicalPaneKey, entry })
paneKeys.add(physicalPaneKey)
paneKeys.add(entry.stablePaneKey)
aliasChanged = true
}
}
this.recordRetiredPaneFence(paneKeys, retiredAliases)
const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys)
const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key))
for (const key of paneKeys) {
@@ -1785,6 +1826,64 @@ export class AgentHookServer {
}
}
// Why: retirement fences a pane and every alias of it, then deletes those aliases.
// Lifting only the key we are handed strands the rest — a detached pane's process
// keeps posting the key it launched under, so it would stay suppressed forever with
// the fence apparently lifted. Replay the recorded fence instead: same key set, same
// aliases. Keys and aliases belonging to a closed tab are skipped, so the stronger
// claim survives and a live process is never routed back into a closed tab.
private restoreRetiredPaneFence(fence: RetiredPaneFence): void {
let aliasChanged = false
for (const { physicalPaneKey, entry } of fence.aliases) {
if (
this.isClosedAgentStatusTabForPaneKey(physicalPaneKey) ||
this.isClosedAgentStatusTabForPaneKey(entry.stablePaneKey) ||
// Why: the pane was rebound in the meantime; the newer alias is the truth.
this.legacyPaneKeyAliases.has(physicalPaneKey)
) {
continue
}
this.legacyPaneKeyAliases.set(physicalPaneKey, entry)
aliasChanged = true
}
for (const key of fence.paneKeys) {
if (this.retiredPaneFencesByKey.get(key) === fence) {
this.retiredPaneFencesByKey.delete(key)
}
}
if (aliasChanged) {
this.boundPaneKeyAliases()
this.notifyPaneKeyAliasPersistenceListener()
}
}
// Why: retirement is a claim that a pane is gone. Re-attaching a live PTY to that
// exact pane disproves the claim at the moment it stops being true, so the fence
// lifts here instead of waiting for the agent to speak again — an agent re-attached
// mid-turn or left idle would otherwise stay suppressed for the rest of its life
// (STA-4114). A closed *tab* is a separate, stronger claim and is left standing.
restorePaneAuthority(paneKey: string): boolean {
const ownerPaneKey = this.resolvePaneKeyAlias(paneKey)
if (this.isClosedAgentStatusTabForPaneKey(ownerPaneKey)) {
return false
}
const fence =
this.retiredPaneFencesByKey.get(paneKey) ?? this.retiredPaneFencesByKey.get(ownerPaneKey)
let restored = false
for (const key of new Set([paneKey, ownerPaneKey, ...(fence?.paneKeys ?? [])])) {
if (this.isClosedAgentStatusTabForPaneKey(key)) {
continue
}
if (this.closedAgentStatusPaneKeys.delete(key)) {
restored = true
}
}
if (fence) {
this.restoreRetiredPaneFence(fence)
}
return restored
}
clearPaneKeyAliasesForPty(
ptyId: string,
options?: { shouldClearStablePaneKey?: (paneKey: string) => boolean }
@@ -2394,6 +2493,7 @@ export class AgentHookServer {
this.promptSentDedupeByPaneKey.clear()
this.closedAgentStatusTabIds.clear()
this.closedAgentStatusPaneKeys.clear()
this.retiredPaneFencesByKey.clear()
this.connectionTimestampWatermarkById.clear()
this.legacyPaneKeyAliases.clear()
clearAllListenerCaches(this.state)
+11
View File
@@ -12,7 +12,18 @@ export function registerAgentPaneAuthorityIpcHandlers(
ownership: AgentPaneAuthorityOwnership
): void {
ipcMain.removeAllListeners('agentStatus:retirePaneAuthority')
ipcMain.removeAllListeners('agentStatus:restorePaneAuthority')
ipcMain.removeAllListeners('agentStatus:transferPaneAuthority')
ipcMain.on('agentStatus:restorePaneAuthority', (_event, paneKey: unknown) => {
if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) {
return
}
try {
agentHookServer.restorePaneAuthority(paneKey)
} catch (err) {
console.warn('[agent-hooks] restorePaneAuthority failed:', err)
}
})
ipcMain.on('agentStatus:retirePaneAuthority', (_event, paneKey: unknown) => {
if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) {
return
+2
View File
@@ -34,6 +34,8 @@ export type AgentStatusApi = {
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
}
+3
View File
@@ -5021,6 +5021,9 @@ const api = {
retirePaneAuthority: (paneKey: string): void => {
ipcRenderer.send('agentStatus:retirePaneAuthority', paneKey)
},
restorePaneAuthority: (paneKey: string): void => {
ipcRenderer.send('agentStatus:restorePaneAuthority', paneKey)
},
transferPaneAuthority: (args: {
fromPaneKey: string
toPaneKey: string
@@ -1,6 +1,7 @@
import type * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { toAppSshPtyId } from '../../../../shared/ssh-pty-id'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { flushAsyncTicks, createDeferred } from './pty-connection-test-async'
import {
LEAF_1,
@@ -274,6 +275,11 @@ describe('connectPanePty', () => {
undefined,
pendingRetry.attemptId
)
// Why: binding a reattached PTY is what lifts the pane's retirement fence, so a
// pane re-attached mid-turn or idle is not suppressed forever (STA-4114).
expect(mockStoreState.restoreAgentPaneAuthority).toHaveBeenCalledWith(
makePaneKey('tab-1', LEAF_1)
)
})
it('rejects expired reattach state after its direct SSH retry lease is revoked', async () => {
@@ -91,6 +91,7 @@ export function createInitialStoreState(getState: () => StoreState): StoreState
removeAgentStatus: vi.fn(),
dropAgentStatus: vi.fn(),
retireAgentPaneAuthority: vi.fn(),
restoreAgentPaneAuthority: vi.fn(),
setPaneForegroundAgent: vi.fn((paneKey: string, entry: PaneForegroundAgentEntry) => {
getState().paneForegroundAgentByPaneKey[paneKey] = entry
}),
@@ -118,6 +118,7 @@ export type StoreState = {
removeAgentStatus: ReturnType<typeof vi.fn>
dropAgentStatus: ReturnType<typeof vi.fn>
retireAgentPaneAuthority: ReturnType<typeof vi.fn>
restoreAgentPaneAuthority: ReturnType<typeof vi.fn>
setPaneForegroundAgent: ReturnType<typeof vi.fn>
clearPaneForegroundAgent: ReturnType<typeof vi.fn>
markTerminalTabUnread: ReturnType<typeof vi.fn>
@@ -3011,6 +3011,10 @@ export function connectPanePty(
registerSideEffectFactConsumerForPty(ptyId)
syncHiddenRendererPtyDelivery()
deps.syncPanePtyLayoutBinding(pane.id, ptyId)
// Why: binding a live PTY here is the proof that this pane is current again, so
// lift any retirement fence left by a detach/reattach cycle before hooks arrive.
// Waiting for a new turn would strand a pane re-attached mid-turn or idle (STA-4114).
useAppStore.getState().restoreAgentPaneAuthority?.(cacheKey)
notifyCodexPaneBoundForStaleSweep(ptyId)
const tabPtyIds = useAppStore.getState().ptyIdsByTabId?.[deps.tabId] ?? []
const directSshRetryAttemptId =
@@ -8189,6 +8193,9 @@ export function connectPanePty(
registerSideEffectFactConsumerForPty(ptyId)
syncHiddenRendererPtyDelivery()
deps.syncPanePtyLayoutBinding(pane.id, ptyId)
// Why: this is the daemon-backed reattach path — the live PTY outlived the
// renderer, so the pane is current the moment it binds (STA-4114).
useAppStore.getState().restoreAgentPaneAuthority?.(cacheKey)
notifyCodexPaneBoundForStaleSweep(ptyId)
if (capturedDirectSshRetryPtyAccepted && directSshRetryAttempt) {
deps.updateTabPtyId(deps.tabId, ptyId, undefined, directSshRetryAttempt.attemptId)
@@ -15,6 +15,7 @@ const FINAL = makePaneKey('tab-final', '33333333-3333-4333-8333-333333333333')
const SIBLING = makePaneKey('tab-target', '44444444-4444-4444-8444-444444444444')
const retirePaneAuthority = vi.fn()
const restorePaneAuthority = vi.fn()
const transferPaneAuthority = vi.fn()
const dropByTabPrefix = vi.fn()
@@ -25,6 +26,7 @@ beforeEach(() => {
api: {
agentStatus: {
retirePaneAuthority,
restorePaneAuthority,
transferPaneAuthority,
dropByTabPrefix,
drop: vi.fn()
@@ -72,6 +74,60 @@ describe('agent pane authority', () => {
expect(retirePaneAuthority).toHaveBeenCalledWith(TARGET)
})
// STA-4114: the renderer tombstone outlived the detach/reattach cycle, so a pane
// that was still running never showed status again for the rest of its life.
it('lifts the retirement fence on re-attach so an in-flight turn can still report done', () => {
const store = createTestStore()
store.getState().setAgentStatus(TARGET, { state: 'working', prompt: 'turn in flight' })
store.getState().retireAgentPaneAuthority(TARGET)
// The pane re-attached mid-turn: the agent never starts a NEW turn, it only
// finishes the one already running, so a turn-triggered revival cannot fire.
store.getState().setAgentStatus(TARGET, { state: 'done', prompt: 'turn in flight' })
expect(store.getState().agentStatusByPaneKey[TARGET]).toBeUndefined()
store.getState().restoreAgentPaneAuthority(TARGET)
expect(store.getState().recentlyRetiredAgentStatusPaneKeys[TARGET]).toBeUndefined()
expect(restorePaneAuthority).toHaveBeenCalledWith(TARGET)
store.getState().setAgentStatus(TARGET, { state: 'done', prompt: 'turn in flight' })
expect(store.getState().agentStatusByPaneKey[TARGET]?.state).toBe('done')
})
it('re-opens a pane re-attached while idle for a turn that starts much later', () => {
const store = createTestStore()
store.getState().retireAgentPaneAuthority(TARGET)
store.getState().restoreAgentPaneAuthority(TARGET)
store.getState().setAgentStatus(TARGET, { state: 'working', prompt: 'much later turn' })
expect(store.getState().agentStatusByPaneKey[TARGET]?.state).toBe('working')
})
it('does not lift a closed-tab tombstone on re-attach', () => {
const store = createTestStore()
store.getState().setAgentStatus(TARGET, { state: 'working', prompt: 'before close' })
store.getState().dropAgentStatusByTabPrefix('tab-target')
store.getState().restoreAgentPaneAuthority(TARGET)
expect(restorePaneAuthority).not.toHaveBeenCalled()
store.getState().setAgentStatus(TARGET, { state: 'working', prompt: 'after close' })
expect(store.getState().agentStatusByPaneKey[TARGET]).toBeUndefined()
})
it('leaves sibling panes untouched when one pane is restored', () => {
const store = createTestStore()
store.getState().retireAgentPaneAuthority(TARGET)
store.getState().retireAgentPaneAuthority(SIBLING)
store.getState().restoreAgentPaneAuthority(TARGET)
expect(store.getState().recentlyRetiredAgentStatusPaneKeys[TARGET]).toBeUndefined()
expect(store.getState().recentlyRetiredAgentStatusPaneKeys[SIBLING]).toBe(true)
store.getState().setAgentStatus(SIBLING, { state: 'working', prompt: 'still fenced' })
expect(store.getState().agentStatusByPaneKey[SIBLING]).toBeUndefined()
})
it('can retire live pane authority while retaining a migration recovery fence', () => {
const store = createTestStore()
store.getState().setAgentStatus(TARGET, { state: 'working', prompt: 'target' })
@@ -204,6 +204,8 @@ export type AgentStatusSlice = {
paneKey: string,
options?: { preserveSleepingAgentSession?: boolean }
) => void
/** Lift a pane's retirement fence once a live PTY re-attaches to it. Closed tabs stay retired. */
restoreAgentPaneAuthority: (paneKey: string) => void
transferAgentPaneAuthority: (args: {
fromPaneKey: string
toPaneKey: string
@@ -1545,6 +1547,48 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
}
},
// Why: the tombstone claims this pane is gone; a live PTY binding to it proves
// otherwise. Lift the fence on that proof rather than on the next hook event —
// a pane re-attached mid-turn or while idle emits no new-turn event, so a
// turn-triggered revival leaves exactly the reported permanent suppression
// (STA-4114). This deliberately does NOT restore the rows retirement dropped;
// those are genuinely stale. It only re-opens the pane to future status.
restoreAgentPaneAuthority: (paneKey) => {
const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey)
// Why: a closed tab is a stronger, separate claim — re-attach must not undo it.
if (
isRecentlyClosedAgentStatusTab(
get().recentlyClosedAgentStatusTabIds,
getTabIdFromPaneKey(ownerPaneKey)
)
) {
return
}
set((s) => {
const restorable = [paneKey, ownerPaneKey].filter(
(key) => key in s.recentlyRetiredAgentStatusPaneKeys
)
if (restorable.length === 0) {
return s
}
const next = { ...s.recentlyRetiredAgentStatusPaneKeys }
for (const key of restorable) {
delete next[key]
}
return { recentlyRetiredAgentStatusPaneKeys: next }
})
// Why: deliberately OUTSIDE the guard above, and not gated on having cleared
// anything here. This map is not a mirror of main's — main fences panes the
// renderer never hears about (retirePtyAgentLaunchAuthority on command-finished
// and PTY exit calls the hook server directly, and nothing pushes that back), and
// this map is per-window and non-persisted, so a renderer reload empties it while
// main's survives. Gating the send on a local tombstone reintroduces STA-4114 for
// exactly those panes. The send is idempotent and main refuses closed tabs itself.
if (typeof window !== 'undefined') {
window.api?.agentStatus?.restorePaneAuthority?.(ownerPaneKey)
}
},
transferAgentPaneAuthority: ({ fromPaneKey, toPaneKey, ptyId }) => {
const transfer = transferAgentPaneAuthorityAlias({ fromPaneKey, toPaneKey, ptyId })
if (!transfer || transfer.previousOwnerPaneKey === transfer.ownerPaneKey) {
+1
View File
@@ -942,6 +942,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
drop: () => {},
dropByTabPrefix: () => {},
retirePaneAuthority: () => {},
restorePaneAuthority: () => {},
transferPaneAuthority: () => {}
},
mobile: {