From be2e31bb7022ea4586f9422e226c1b22c64f43d8 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 27 Oct 2026 13:08:35 -0700 Subject: [PATCH] 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 Signed-off-by: Tom Brow --- mobile/lib/shared/agents/remote_agents.dart | 54 +++++++++++++++---- .../test/helpers/remote_agents_fixture.dart | 2 + .../shared/agents/remote_agents_test.dart | 45 ++++++++++++++-- 3 files changed, 88 insertions(+), 13 deletions(-) diff --git a/mobile/lib/shared/agents/remote_agents.dart b/mobile/lib/shared/agents/remote_agents.dart index 3ef338f82a..561d2aaf7b 100644 --- a/mobile/lib/shared/agents/remote_agents.dart +++ b/mobile/lib/shared/agents/remote_agents.dart @@ -50,8 +50,12 @@ final availableRemoteAgentsProvider = FutureProvider>((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 refresh() async { final session = account.account ?? await account.restore(); if (session == null) { @@ -353,7 +366,13 @@ class RemoteAgentController extends ChangeNotifier { }); } var setup = Map.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 refused() async { + setup = {...setup}..remove('registering'); + await _savePending(setup); + } + + final Map 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( diff --git a/mobile/test/helpers/remote_agents_fixture.dart b/mobile/test/helpers/remote_agents_fixture.dart index 386d53eda9..6ad37c4fbf 100644 --- a/mobile/test/helpers/remote_agents_fixture.dart +++ b/mobile/test/helpers/remote_agents_fixture.dart @@ -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 = []; final bodies = >{}; @@ -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'}; } diff --git a/mobile/test/shared/agents/remote_agents_test.dart b/mobile/test/shared/agents/remote_agents_test.dart index 351be017ce..b8b12718c9 100644 --- a/mobile/test/shared/agents/remote_agents_test.dart +++ b/mobile/test/shared/agents/remote_agents_test.dart @@ -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', () {