fix(mobile): recover rejected and lost agent registrations anywhere

An HTTP rejection of register-agent is as definite as a rejected status,
so it clears the registering mark and the setup can be discarded. A
registration whose response was lost can be replayed from any
community to learn its identity; finishing still needs the original
community. Publishing or removing a registration now refreshes remote
availability even when the registry's ACTIVE set is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-27 13:08:35 -07:00
parent 1c852eda3b
commit be2e31bb70
3 changed files with 88 additions and 13 deletions

View File

@@ -50,8 +50,12 @@ final availableRemoteAgentsProvider = FutureProvider<Set<String>>((ref) async {
ref.watch(relaySessionProvider.select((session) => session.status));
final controller = ref.watch(remoteAgentControllerProvider);
final active = controller.activeAgents;
final revision = controller.registrationRevision;
void changed() {
if (!setEquals(controller.activeAgents, active)) ref.invalidateSelf();
if (!setEquals(controller.activeAgents, active) ||
controller.registrationRevision != revision) {
ref.invalidateSelf();
}
}
controller.addListener(changed);
@@ -217,6 +221,15 @@ class RemoteAgentController extends ChangeNotifier {
if (!_disposed) notifyListeners();
}
/// Bumped when this device publishes or removes a kind 30177 registration,
/// which changes availability even when the registry's ACTIVE set does not.
int registrationRevision = 0;
void _registrationsChanged() {
registrationRevision++;
_notify();
}
Future<void> refresh() async {
final session = account.account ?? await account.restore();
if (session == null) {
@@ -353,7 +366,13 @@ class RemoteAgentController extends ChangeNotifier {
});
}
var setup = Map<String, dynamic>.from(pending!);
if (setup['owner'] != owner || setup['community'] != community.relayUrl) {
// A registration whose response was lost may be replayed from any
// community, so the account learns its identity even if the original
// community is unavailable. Everything after it needs that community.
final elsewhere = setup['community'] != community.relayUrl;
final unknownIdentity =
setup['pubkey'] == null && setup['registering'] == true;
if (setup['owner'] != owner || (elsewhere && !unknownIdentity)) {
throw StateError('Finish the pending setup in its original community.');
}
if (setup['instructionsMissing'] == true) {
@@ -371,16 +390,26 @@ class RemoteAgentController extends ChangeNotifier {
// lost. Only Resume, with the same idempotency key, can recover it.
setup = {...setup, 'registering': true};
await _savePending(setup);
final result = await account.post('/v3/beekeeper/register-agent', {
'agent_name': setup['name'],
'idempotency_key': setup['request'],
});
// An explicit rejection allocated nothing, so it can be discarded.
Future<void> refused() async {
setup = {...setup}..remove('registering');
await _savePending(setup);
}
final Map<String, dynamic> result;
try {
result = await account.post('/v3/beekeeper/register-agent', {
'agent_name': setup['name'],
'idempotency_key': setup['request'],
});
} on AccountFailure catch (failure) {
if (failure == AccountFailure.rejected) await refused();
rethrow;
}
if (!hasStatus(result, 'UNATTESTED', 1) &&
result['status'] != null &&
result['agent_pubkey'] == null) {
// An explicit rejection allocated nothing, so it can be discarded.
setup = {...setup}..remove('registering');
await _savePending(setup);
await refused();
}
requireStatus(result, 'UNATTESTED', 1);
final pubkey = result['agent_pubkey'];
@@ -392,6 +421,11 @@ class RemoteAgentController extends ChangeNotifier {
setup = {...setup, 'pubkey': pubkey, 'id': result['agent_id']};
await _savePending(setup);
}
if (elsewhere) {
throw StateError(
'Agent identity recovered. Finish the setup in its original community.',
);
}
final pubkey = setup['pubkey'] as String;
status = 'Authorizing and activating agent…';
_notify();
@@ -412,6 +446,7 @@ class RemoteAgentController extends ChangeNotifier {
}
await subscribe(setup['id'] as String, community.relayUrl);
await publishPolicy(community.relayUrl, setup);
_registrationsChanged();
// Retain the completed registration if the listing refresh fails, so Retry
// resumes the same identity instead of creating another agent.
await refresh();
@@ -577,6 +612,7 @@ class RemoteAgentController extends ChangeNotifier {
}
check();
await removePolicy(community.relayUrl, pubkey);
_registrationsChanged();
check();
final remaining = await fetch(
NostrFilter(

View File

@@ -42,6 +42,7 @@ class FakeRegistry extends BuilderlabAccountClient {
bool failInstructions = false;
bool rejectRegistration = false;
bool loseRegistration = false;
bool refuseRegistration = false;
Object deleteStatus = 1;
final calls = <String>[];
final bodies = <String, Map<String, dynamic>>{};
@@ -68,6 +69,7 @@ class FakeRegistry extends BuilderlabAccountClient {
case '/v3/beekeeper/list-agents':
return {'status': 1, 'agents': listed};
case '/v3/beekeeper/register-agent':
if (refuseRegistration) throw AccountFailure.rejected;
if (rejectRegistration) {
return {'status': 'REGISTER_AGENT_STATUS_NAME_TAKEN'};
}

View File

@@ -338,6 +338,13 @@ void main() {
);
expect(controller.status, contains('NAME_TAKEN'));
expect(controller.canDiscardPending, isTrue);
// An HTTP rejection is just as definite.
registry
..rejectRegistration = false
..refuseRegistration = true;
expect(await controller.create(community, 'Scout'), isFalse);
expect(controller.status, contains('rejected'));
expect(controller.canDiscardPending, isTrue);
final request = controller.pending!['request'];
expect(prefs.getString('$_scope:pending'), isNotNull);
@@ -351,7 +358,7 @@ void main() {
// A lost response may hide a committed identity, so it can only resume.
registry
..rejectRegistration = false
..refuseRegistration = false
..loseRegistration = true;
expect(
await controller.create(community, 'Scout', instructions: 'Private.'),
@@ -385,16 +392,21 @@ void main() {
expect(await remove(other), isFalse);
expect(controller.status, contains('Resume the unfinished setup'));
// Resume reuses the idempotency key and recovers the same identity.
// Resume from another community reuses the idempotency key and recovers
// the same identity; finishing it still needs the original community.
registry
..loseRegistration = false
..failInstructions = true
..bodies.clear();
expect(await controller.create(community, 'Scout'), isFalse);
expect(await controller.create(elsewhere, 'Scout'), isFalse);
expect(controller.status, contains('Agent identity recovered'));
expect(
registry.bodies['/v3/beekeeper/register-agent']?['idempotency_key'],
lost,
);
registry.bodies.clear();
expect(await controller.create(elsewhere, 'Scout'), isFalse);
expect(controller.status, contains('original community'));
expect(registry.bodies, isEmpty);
expect(controller.pending?['pubkey'], fakeAgent);
expect(await controller.discardPending(), isFalse);
expect(controller.pending?['pubkey'], fakeAgent);
@@ -519,6 +531,31 @@ void main() {
fakeAgent,
});
expect(container.read(presentedPresenceProvider)[fakeAgent], 'online');
// Removing the registration hides it even when the runtime stays ACTIVE.
registry.deleteStatus = 2;
relay.live = false;
expect(
await controller.deleteAgent(
pubkey: fakeAgent,
community: _community(),
isCurrent: () => true,
fetch: (_) async => [],
),
isFalse,
);
expect(controller.activeAgents, {fakeAgent});
expect(await container.read(availableRemoteAgentsProvider.future), isEmpty);
expect(container.read(presentedPresenceProvider)[fakeAgent], 'offline');
// Publishing a registration for the still-ACTIVE agent shows it again.
relay.live = true;
expect(await controller.create(_community(), 'Scout'), isTrue);
expect(controller.activeAgents, {fakeAgent});
expect(await container.read(availableRemoteAgentsProvider.future), {
fakeAgent,
});
expect(container.read(presentedPresenceProvider)[fakeAgent], 'online');
}, skip: !HostedEnvironment.enabled);
test('owner proof verifies for only the provisioned agent key', () {