Some time after that I would expect the recovery manager to retry commiting that xaresource by calling recover() on a XAresource returned from our XAResourceRecovery instance, but the recovery manager never attempts this.

I can see the period first and second pass occurring, but as I say, they simply call hasMoreResources() which returns false, and the branch never gets committed.

If your resource throws a heuristic exception then it is supposed to have logged that fact. Did it? Also, is there a transaction log entry in the object store corresponding to the transaction within which your heuristic exception is caught?

The resource doesn't throw a heuristic exception, the resource throws a XAException.XAER_RMERR - arjuna catches this and throws a heuristic back up to the caller of commit(), this can be seen in the logs: