fix(agent-status): close lifecycle recovery edge cases

This commit is contained in:
Brennan Benson
2026-09-15 11:15:59 -07:00
parent f4b64419b2
commit 30efa1bf8d
8 changed files with 95 additions and 12 deletions
+3 -3
View File
@@ -91,13 +91,13 @@ export function bindProviderListeners(session: PtyIpcSession): void {
return
}
if (!isLocalProvider) {
clearProviderPtyState(payload.id)
ptyOwnership.delete(payload.id)
markClaudePtyExited(payload.id)
session.runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId, {
providerExitObserved: true,
...(payload.cause ? { cause: payload.cause } : {})
})
clearProviderPtyState(payload.id)
ptyOwnership.delete(payload.id)
markClaudePtyExited(payload.id)
}
// Why not the whole payload: the exit cause is a main-process fact for the
// runtime's records; the renderer's pty:exit contract stays as it was.
+3 -3
View File
@@ -102,13 +102,13 @@ export function configureLocalPtyProvider(args: {
if (!isCurrentPtyExit({ id, incarnationId })) {
return
}
clearProviderPtyState(id)
ptyOwnership.delete(id)
markClaudePtyExited(id)
runtime?.onPtyExit(id, code, incarnationId, {
providerExitObserved: true,
...(cause ? { cause } : {})
})
clearProviderPtyState(id)
ptyOwnership.delete(id)
markClaudePtyExited(id)
},
onData: (id, data, timestamp, sequenceChars, transformed) =>
runtime?.onPtyData(id, data, timestamp, sequenceChars ?? data.length, transformed)
+1 -1
View File
@@ -2279,6 +2279,7 @@ export class SshRelaySession {
private retireExitedPty(payload: SshPtyExitPayload, deliveryHandled = false): void {
const relayPtyId = toRelaySshPtyId(this.targetId, payload.id)
this.retiredSourceDeliveries.activate(relayPtyId)
this.runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId)
clearProviderPtyState(payload.id)
deletePtyOwnership(payload.id)
this.rejectedPtyRecoveryAttempts.delete(payload.id)
@@ -2290,7 +2291,6 @@ export class SshRelaySession {
if (deliveryHandled) {
return
}
this.runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId)
const win = this.getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send('pty:exit', payload)
@@ -198,6 +198,16 @@ describe('provider turn evidence adapter', () => {
true
)
).toBeNull()
expect(
providerCurrentTurnInventory(
{
turnId: 'turn-1',
joinedChildren: [{ id: 'duplicate', phase: 'active' }],
residentBackground: [{ id: 'duplicate', phase: 'active' }]
},
true
)
).toBeNull()
expect(
providerCurrentTurnInventory(
{
@@ -92,6 +92,10 @@ export function providerCurrentTurnInventory(
if (joinedChildren === null || residentBackground === null) {
return null
}
const workIds = new Set([...joinedChildren, ...residentBackground].map((item) => item.workId))
if (workIds.size !== joinedChildren.length + residentBackground.length) {
return null
}
const startedAt = optionalTimestamp(record.startedAt)
if (startedAt === null) {
return null
@@ -48,6 +48,7 @@ export function recoveryStarted(
lastEvidence: eventEvidence(event)
})
) {
state.recoveries = state.recoveries.filter((entry) => entry.custodyId !== event.custodyId)
issue(state, {
kind: 'capacity-overflow',
turnId: event.turnId,
@@ -148,7 +148,7 @@ function deriveDispatchOutcome(
state: AgentTurnLifecycleState,
dispatch: AgentTurnDispatchRecord
): AgentTurnDispatchRecord['outcome'] {
if (dispatch.receipt !== 'received' || dispatch.outcome !== null) {
if (dispatch.receipt !== 'received') {
return dispatch.outcome
}
const turn = findTurn(state, dispatch.turnId)
@@ -167,6 +167,21 @@ function deriveDispatchOutcome(
if (turn.outcome !== 'completed') {
return turn.outcome
}
const joined = state.work.filter(
(item) => item.turnId === dispatch.turnId && item.kind === 'joined-child'
)
if (dispatch.outcome === 'completed') {
return joined.some(
(item) =>
item.phase === 'active' ||
item.phase === 'unresolved' ||
item.phase === 'abandoned' ||
item.outcome === 'failed' ||
item.outcome === 'interrupted'
)
? 'unresolved'
: 'completed'
}
if (
state.integrityIssues.some(
(entry) => entry.kind === 'capacity-overflow' && entry.turnId === dispatch.turnId
@@ -174,9 +189,6 @@ function deriveDispatchOutcome(
) {
return 'unresolved'
}
const joined = state.work.filter(
(item) => item.turnId === dispatch.turnId && item.kind === 'joined-child'
)
if (joined.some((item) => item.phase === 'active')) {
return null
}
@@ -192,7 +204,7 @@ function deriveDispatchOutcome(
export function reconcileDispatches(state: AgentTurnLifecycleState, observedAt: number): void {
for (const dispatch of state.dispatches) {
const outcome = deriveDispatchOutcome(state, dispatch)
if (outcome !== null && dispatch.outcome === null) {
if (outcome !== null && outcome !== dispatch.outcome) {
dispatch.outcome = outcome
dispatch.settledAt = observedAt
}
@@ -124,6 +124,42 @@ describe('canonical agent turn lifecycle reducer', () => {
])
})
it('reopens a completed dispatch when a late joined child makes completion uncertain', () => {
let current = state()
current = apply(current, event({ kind: 'turn-started', turnId: 'turn-late-child' })).state
current = apply(
current,
event({ kind: 'dispatch-associated', dispatchId: 'dispatch-late', turnId: 'turn-late-child' })
).state
current = apply(
current,
event({ kind: 'dispatch-received', dispatchId: 'dispatch-late', turnId: 'turn-late-child' })
).state
current = apply(
current,
event({
kind: 'turn-outcome-observed',
turnId: 'turn-late-child',
outcome: 'completed',
recordKind: 'event'
})
).state
expect(current.dispatches[0]).toMatchObject({ outcome: 'completed' })
const lateChild = apply(
current,
event({
kind: 'work-started',
turnId: 'turn-late-child',
workId: 'child-after-root',
workKind: 'joined-child'
})
)
expect(lateChild.state.dispatches[0]).toMatchObject({ outcome: 'unresolved' })
expect(lateChild.committedDispatches).toEqual([
expect.objectContaining({ dispatchId: 'dispatch-late', outcome: 'unresolved' })
])
})
it('allows an attributable terminal record to recover a missed start, but not an ordinary end', () => {
const ordinary = apply(
state(),
@@ -298,6 +334,26 @@ describe('canonical agent turn lifecycle reducer', () => {
)
})
it('does not retain recovery custody when turn admission is at capacity', () => {
let current = state()
for (let index = 0; index < 128; index += 1) {
current = apply(current, event({ kind: 'turn-started', turnId: `turn-${index}` })).state
}
const rejected = apply(
current,
event({
kind: 'turn-recovery-started',
turnId: 'turn-overflow',
custodyId: 'custody-overflow',
deadlineAt: 1000
})
)
expect(rejected.reason).toBe('capacity')
expect(rejected.state.recoveries).not.toContainEqual(
expect.objectContaining({ custodyId: 'custody-overflow' })
)
})
it('deduplicates replay and keeps a stable completion identity', () => {
const first = apply(
state(),