diff --git a/src/main/ipc/pty/provider/bind-listeners.ts b/src/main/ipc/pty/provider/bind-listeners.ts index 8febd1e3534..b0bfa4dc2fa 100644 --- a/src/main/ipc/pty/provider/bind-listeners.ts +++ b/src/main/ipc/pty/provider/bind-listeners.ts @@ -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. diff --git a/src/main/ipc/pty/provider/local-configure.ts b/src/main/ipc/pty/provider/local-configure.ts index 1ebf0248e12..d34e630975f 100644 --- a/src/main/ipc/pty/provider/local-configure.ts +++ b/src/main/ipc/pty/provider/local-configure.ts @@ -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) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index b52fcb81d6f..7b72bfeb41b 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -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) diff --git a/src/shared/agent-hook-listener/provider-turn-evidence.test.ts b/src/shared/agent-hook-listener/provider-turn-evidence.test.ts index 6b442952b95..9eb235655fd 100644 --- a/src/shared/agent-hook-listener/provider-turn-evidence.test.ts +++ b/src/shared/agent-hook-listener/provider-turn-evidence.test.ts @@ -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( { diff --git a/src/shared/agent-hook-listener/provider-turn-inventory.ts b/src/shared/agent-hook-listener/provider-turn-inventory.ts index 80422a497e7..e249e60c5ea 100644 --- a/src/shared/agent-hook-listener/provider-turn-inventory.ts +++ b/src/shared/agent-hook-listener/provider-turn-inventory.ts @@ -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 diff --git a/src/shared/agent-turn-lifecycle-reducer-recovery.ts b/src/shared/agent-turn-lifecycle-reducer-recovery.ts index 894fb296b60..34b9da37438 100644 --- a/src/shared/agent-turn-lifecycle-reducer-recovery.ts +++ b/src/shared/agent-turn-lifecycle-reducer-recovery.ts @@ -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, diff --git a/src/shared/agent-turn-lifecycle-reducer-transitions.ts b/src/shared/agent-turn-lifecycle-reducer-transitions.ts index ad056667bd5..037335f1520 100644 --- a/src/shared/agent-turn-lifecycle-reducer-transitions.ts +++ b/src/shared/agent-turn-lifecycle-reducer-transitions.ts @@ -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 } diff --git a/src/shared/agent-turn-lifecycle-reducer.test.ts b/src/shared/agent-turn-lifecycle-reducer.test.ts index f87b801171b..7744f974f6f 100644 --- a/src/shared/agent-turn-lifecycle-reducer.test.ts +++ b/src/shared/agent-turn-lifecycle-reducer.test.ts @@ -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(),