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)); ref.watch(relaySessionProvider.select((session) => session.status));
final controller = ref.watch(remoteAgentControllerProvider); final controller = ref.watch(remoteAgentControllerProvider);
final active = controller.activeAgents; final active = controller.activeAgents;
final revision = controller.registrationRevision;
void changed() { void changed() {
if (!setEquals(controller.activeAgents, active)) ref.invalidateSelf(); if (!setEquals(controller.activeAgents, active) ||
controller.registrationRevision != revision) {
ref.invalidateSelf();
}
} }
controller.addListener(changed); controller.addListener(changed);
@@ -217,6 +221,15 @@ class RemoteAgentController extends ChangeNotifier {
if (!_disposed) notifyListeners(); 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 { Future<void> refresh() async {
final session = account.account ?? await account.restore(); final session = account.account ?? await account.restore();
if (session == null) { if (session == null) {
@@ -353,7 +366,13 @@ class RemoteAgentController extends ChangeNotifier {
}); });
} }
var setup = Map<String, dynamic>.from(pending!); 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.'); throw StateError('Finish the pending setup in its original community.');
} }
if (setup['instructionsMissing'] == true) { if (setup['instructionsMissing'] == true) {
@@ -371,16 +390,26 @@ class RemoteAgentController extends ChangeNotifier {
// lost. Only Resume, with the same idempotency key, can recover it. // lost. Only Resume, with the same idempotency key, can recover it.
setup = {...setup, 'registering': true}; setup = {...setup, 'registering': true};
await _savePending(setup); await _savePending(setup);
final result = await account.post('/v3/beekeeper/register-agent', { // An explicit rejection allocated nothing, so it can be discarded.
'agent_name': setup['name'], Future<void> refused() async {
'idempotency_key': setup['request'], 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) && if (!hasStatus(result, 'UNATTESTED', 1) &&
result['status'] != null && result['status'] != null &&
result['agent_pubkey'] == null) { result['agent_pubkey'] == null) {
// An explicit rejection allocated nothing, so it can be discarded. await refused();
setup = {...setup}..remove('registering');
await _savePending(setup);
} }
requireStatus(result, 'UNATTESTED', 1); requireStatus(result, 'UNATTESTED', 1);
final pubkey = result['agent_pubkey']; final pubkey = result['agent_pubkey'];
@@ -392,6 +421,11 @@ class RemoteAgentController extends ChangeNotifier {
setup = {...setup, 'pubkey': pubkey, 'id': result['agent_id']}; setup = {...setup, 'pubkey': pubkey, 'id': result['agent_id']};
await _savePending(setup); await _savePending(setup);
} }
if (elsewhere) {
throw StateError(
'Agent identity recovered. Finish the setup in its original community.',
);
}
final pubkey = setup['pubkey'] as String; final pubkey = setup['pubkey'] as String;
status = 'Authorizing and activating agent…'; status = 'Authorizing and activating agent…';
_notify(); _notify();
@@ -412,6 +446,7 @@ class RemoteAgentController extends ChangeNotifier {
} }
await subscribe(setup['id'] as String, community.relayUrl); await subscribe(setup['id'] as String, community.relayUrl);
await publishPolicy(community.relayUrl, setup); await publishPolicy(community.relayUrl, setup);
_registrationsChanged();
// Retain the completed registration if the listing refresh fails, so Retry // Retain the completed registration if the listing refresh fails, so Retry
// resumes the same identity instead of creating another agent. // resumes the same identity instead of creating another agent.
await refresh(); await refresh();
@@ -577,6 +612,7 @@ class RemoteAgentController extends ChangeNotifier {
} }
check(); check();
await removePolicy(community.relayUrl, pubkey); await removePolicy(community.relayUrl, pubkey);
_registrationsChanged();
check(); check();
final remaining = await fetch( final remaining = await fetch(
NostrFilter( NostrFilter(

View File

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

View File

@@ -338,6 +338,13 @@ void main() {
); );
expect(controller.status, contains('NAME_TAKEN')); expect(controller.status, contains('NAME_TAKEN'));
expect(controller.canDiscardPending, isTrue); 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']; final request = controller.pending!['request'];
expect(prefs.getString('$_scope:pending'), isNotNull); 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. // A lost response may hide a committed identity, so it can only resume.
registry registry
..rejectRegistration = false ..refuseRegistration = false
..loseRegistration = true; ..loseRegistration = true;
expect( expect(
await controller.create(community, 'Scout', instructions: 'Private.'), await controller.create(community, 'Scout', instructions: 'Private.'),
@@ -385,16 +392,21 @@ void main() {
expect(await remove(other), isFalse); expect(await remove(other), isFalse);
expect(controller.status, contains('Resume the unfinished setup')); 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 registry
..loseRegistration = false ..loseRegistration = false
..failInstructions = true
..bodies.clear(); ..bodies.clear();
expect(await controller.create(community, 'Scout'), isFalse); expect(await controller.create(elsewhere, 'Scout'), isFalse);
expect(controller.status, contains('Agent identity recovered'));
expect( expect(
registry.bodies['/v3/beekeeper/register-agent']?['idempotency_key'], registry.bodies['/v3/beekeeper/register-agent']?['idempotency_key'],
lost, 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(controller.pending?['pubkey'], fakeAgent);
expect(await controller.discardPending(), isFalse); expect(await controller.discardPending(), isFalse);
expect(controller.pending?['pubkey'], fakeAgent); expect(controller.pending?['pubkey'], fakeAgent);
@@ -519,6 +531,31 @@ void main() {
fakeAgent, fakeAgent,
}); });
expect(container.read(presentedPresenceProvider)[fakeAgent], 'online'); 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); }, skip: !HostedEnvironment.enabled);
test('owner proof verifies for only the provisioned agent key', () { test('owner proof verifies for only the provisioned agent key', () {