mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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' })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
@@ -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) {
|
||||
|
||||
@@ -942,6 +942,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
|
||||
drop: () => {},
|
||||
dropByTabPrefix: () => {},
|
||||
retirePaneAuthority: () => {},
|
||||
restorePaneAuthority: () => {},
|
||||
transferPaneAuthority: () => {}
|
||||
},
|
||||
mobile: {
|
||||
|
||||
Reference in New Issue
Block a user