Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 153 additions & 2 deletions src/main/agent-hooks/server-process-lifetime-status.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentHookServer } from './server'
import { buildBody, PANE, postHookEvent } from './server.test-fixtures'
import { buildBody, LEAF_1, PANE, postHookEvent } from './server.test-fixtures'
import { makePaneKey } from '../../shared/stable-pane-id'

let dir: string
let server: AgentHookServer
Expand All @@ -15,6 +16,7 @@ beforeEach(async () => {
})

afterEach(() => {
vi.restoreAllMocks()
server.stop()
rmSync(dir, { recursive: true, force: true })
})
Expand Down Expand Up @@ -44,6 +46,10 @@ function paneState(): string {
return server.getStatusSnapshotForPane(PANE)[0]?.state ?? 'missing'
}

function paneReceivedAt(): number | undefined {
return server.getStatusSnapshotForPane(PANE)[0]?.receivedAt
}

async function nextMillisecond(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 5))
}
Expand Down Expand Up @@ -109,4 +115,149 @@ describe('process-lifetime status', () => {
processLifetime('done', Date.now())
expect(paneState()).toBe('missing')
})

// Why: a pane holding launch authority retires it at the 133;D that ends the run.
it('keeps the Done of a run whose own command end retired the pane, and fences what follows', async () => {
const commandStartedAt = Date.now()
processLifetime('working', commandStartedAt)
await nextMillisecond()
server.retirePaneAuthority(PANE)
expect(paneState()).toBe('missing')

processLifetime('done', commandStartedAt)
expect(paneState()).toBe('done')

// A late post from the retired launch is still suppressed: the Done did not lift the fence.
await openCodeHook('SessionBusy')
expect(paneState()).toBe('done')
server.ingestTerminalStatus({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
connectionId: null,
payload: { state: 'working', prompt: '', agentType: 'opencode' }
})
expect(paneState()).toBe('done')
})

it('suppresses a stale hook post after retirement before the run reports its end', async () => {
const commandStartedAt = Date.now()
processLifetime('working', commandStartedAt)
await nextMillisecond()
server.retirePaneAuthority(PANE)

await openCodeHook('SessionBusy')
expect(paneState()).toBe('missing')
})

it('keeps the Done of a retired run out of a closed tab', async () => {
const commandStartedAt = Date.now()
processLifetime('working', commandStartedAt)
await nextMillisecond()
server.retirePaneAuthority(PANE)
server.dropStatusEntriesByTabPrefix('tab-1')

processLifetime('done', commandStartedAt)
expect(paneState()).toBe('missing')
})

it.each([
['still working', ['SessionBusy']],
['already done', ['SessionBusy', 'SessionIdle']]
])(
'keeps the process Done of a hook-owned run out of a retired pane (hook %s)',
async (_, hooks) => {
const commandStartedAt = Date.now()
processLifetime('working', commandStartedAt)
for (const hook of hooks) {
await openCodeHook(hook)
}
server.retirePaneAuthority(PANE)

processLifetime('done', commandStartedAt)
expect(paneState()).toBe('missing')
}
)

it('keeps the Done of a retired run whatever the wall clock reads at retirement', () => {
const commandStartedAt = Date.now()
const clock = vi.spyOn(Date, 'now').mockReturnValue(commandStartedAt)
processLifetime('working', commandStartedAt)
clock.mockReturnValue(commandStartedAt - 60_000)
server.retirePaneAuthority(PANE)

processLifetime('done', commandStartedAt)
expect(paneState()).toBe('done')
})

// Why: a command end retires each of the pane's keys, so the later retirements find no rows.
it('keeps the Done when the pane retires again before the run reports its end', () => {
const commandStartedAt = Date.now()
processLifetime('working', commandStartedAt)
server.retirePaneAuthority(PANE)
server.retirePaneAuthority(PANE)

processLifetime('done', commandStartedAt)
expect(paneState()).toBe('done')
})

it('settles a retired run once', async () => {
const commandStartedAt = Date.now()
processLifetime('working', commandStartedAt)
server.retirePaneAuthority(PANE)
processLifetime('done', commandStartedAt)
const settledAt = paneReceivedAt()
expect(settledAt).toBeDefined()

await nextMillisecond()
processLifetime('done', commandStartedAt)
expect(paneReceivedAt()).toBe(settledAt)
})

it('does not settle a run that had already ended when the pane retired', () => {
const commandStartedAt = Date.now()
processLifetime('working', commandStartedAt)
processLifetime('done', commandStartedAt)
server.retirePaneAuthority(PANE)

processLifetime('done', commandStartedAt)
expect(paneState()).toBe('missing')
})

it('does not let a run captured before a newer run settle that newer run', () => {
const firstStartedAt = Date.now()
processLifetime('working', firstStartedAt)
server.retirePaneAuthority(PANE)
const secondStartedAt = Date.now()
processLifetime('working', secondStartedAt)
expect(paneState()).toBe('working')
// The newer run's row is dismissed, so its retirement captures nothing of its own.
server.dropStatusEntry(PANE, { preserveResumeIdentity: false })
server.retirePaneAuthority(PANE)

processLifetime('done', secondStartedAt)
expect(paneState()).toBe('missing')
})

// Why: moving the pane to another tab and back lifts its retirement without a new-turn restart.
it('does not carry a run over from a retirement the pane has since left', async () => {
const firstStartedAt = Date.now()
processLifetime('working', firstStartedAt)
server.retirePaneAuthority(PANE)
const otherTabPane = makePaneKey('tab-2', LEAF_1)
server.transferPaneAuthority(PANE, otherTabPane, 'pty-1')
server.transferPaneAuthority(otherTabPane, PANE, 'pty-1')
await nextMillisecond()

// The next run is hook-owned (OpenCode 1 style); its row is dismissed before its command end.
const secondStartedAt = Date.now()
processLifetime('working', secondStartedAt)
await openCodeHook('SessionBusy')
expect(paneState()).toBe('working')
server.dropStatusEntry(PANE, { preserveResumeIdentity: false })
server.retirePaneAuthority(PANE)

processLifetime('done', secondStartedAt)
expect(paneState()).toBe('missing')
})
})
25 changes: 19 additions & 6 deletions src/main/agent-hooks/server/server-authority-fences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth
retirePaneAuthority(paneKey: string, retirementId?: string): void {
const ownerPaneKey = this.resolvePaneKeyAlias(paneKey)
const previousFence = this.retiredPaneFencesByKey.get(ownerPaneKey)
// Why: before the closed marks are re-added; a pane that left retirement has no run to carry over.
const previousRun = this.closedAgentStatusPaneKeys.has(ownerPaneKey)
? previousFence?.retiredRun
: undefined
const paneKeys = new Set([paneKey, ownerPaneKey])
for (const key of previousFence?.paneKeys ?? []) {
if (
Expand All @@ -35,18 +39,27 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth
aliasChanged = true
}
}
this.recordRetiredPaneFence(
paneKeys,
retiredAliases,
this.isClosedAgentStatusTabForPaneKey(ownerPaneKey) ? undefined : retirementId
)
const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys)
const retiredRows = [...paneKeys].flatMap((key) => {
const row = this.state.lastStatusByPaneKey.get(key) as
| EnrichedAgentHookEventPayload
| undefined
return row ? [row] : []
})
const liveRow = retiredRows.find((row) => row.paneKey === ownerPaneKey) ?? retiredRows[0]
this.recordRetiredPaneFence(
paneKeys,
retiredAliases,
this.isClosedAgentStatusTabForPaneKey(ownerPaneKey) ? undefined : retirementId,
liveRow
? {
state: liveRow.payload.state,
origin: liveRow.observation?.origin,
receivedAt: liveRow.receivedAt
}
: // Why: an earlier retirement of this pane (another of its keys, or a mid-run one) deleted the row.
previousRun
)
const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys)
const hadStatus = retiredRows.length > 0
for (const key of paneKeys) {
this.markPaneClosedForAgentStatus(key)
Expand Down
35 changes: 25 additions & 10 deletions src/main/agent-hooks/server/server-ingest-terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ import type { EnrichedAgentHookEventPayload } from './server-types'
import { isAgentStatusHeldOpenByChildWork } from '../../../shared/agent-lead-status-fold'
import { AgentHookServerIngestNormalization } from './server-ingest-normalization'

// Why: a verified process-lifetime Working proves a new agent run, as a hook new-turn event does,
// and its Done ends that run.
function processLifetimeEvent(event: {
origin?: 'process'
payload: ParsedAgentStatusPayload
yieldsToHookSince?: number
}): { processNewTurn: true } | { endedProcessRunStartedAt: number } | undefined {
if (event.origin !== 'process') {
return undefined
}
if (event.payload.state === 'working') {
return { processNewTurn: true }
}
return event.payload.state === 'done' && event.yieldsToHookSince !== undefined
? { endedProcessRunStartedAt: event.yieldsToHookSince }
: undefined
}

export abstract class AgentHookServerIngestTerminal extends AgentHookServerIngestNormalization {
ingestTerminalStatus(event: {
ptyId?: string
Expand Down Expand Up @@ -47,13 +65,7 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges
return
}
const tabId = paneKey !== physicalPaneKey ? parsedPaneKey?.tabId : reportedTabId
// Why: a verified process-lifetime Working proves a new agent run, as a hook new-turn event does.
const disposition = this.getAgentStatusDisposition(
paneKey,
event.origin === 'process' && event.payload.state === 'working'
? { processNewTurn: true }
: undefined
)
const disposition = this.getAgentStatusDisposition(paneKey, processLifetimeEvent(event))
if (disposition === 'suppress') {
return
}
Expand Down Expand Up @@ -96,11 +108,14 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges
const previous = this.state.lastStatusByPaneKey.get(paneKey) as
| EnrichedAgentHookEventPayload
| undefined
// Why: a hook that reported during this command owns it (OpenCode 1 `run` loads its plugin in-process).
const hookOwnsCommand =
event.yieldsToHookSince !== undefined &&
previous?.observation?.origin === 'hook' &&
previous.receivedAt >= event.yieldsToHookSince
previous !== undefined &&
this.hookOwnsProcessRun(
previous.observation?.origin,
previous.receivedAt,
event.yieldsToHookSince
)
if (
hookOwnsCommand ||
(previous?.payload.agentType === 'claude' &&
Expand Down
4 changes: 3 additions & 1 deletion src/main/agent-hooks/server/server-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import type {
ProviderSessionChangeListener,
RetiredPaneAlias,
RetiredPaneFence,
RetiredPaneRun,
ServerAgentStatusListener,
ServerStatusLineListener,
StatusChangeListener,
Expand Down Expand Up @@ -186,7 +187,8 @@ export abstract class AgentHookServerState {
protected abstract recordRetiredPaneFence(
paneKeys: ReadonlySet<string>,
aliases: readonly RetiredPaneAlias[],
retirementId?: string
retirementId?: string,
retiredRun?: RetiredPaneRun
): void
protected abstract markPaneClosedForAgentStatus(paneKey: string): void
protected abstract attachStatusTiming(
Expand Down
Loading
Loading