diff --git a/lib/core/sync/account_sync_manager.dart b/lib/core/sync/account_sync_manager.dart index 75bfa49..91a45ea 100644 --- a/lib/core/sync/account_sync_manager.dart +++ b/lib/core/sync/account_sync_manager.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:enough_mail/enough_mail.dart' as imap; +import 'package:flutter/services.dart' show MissingPluginException; import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/email.dart' show SyncEmailsResult; import 'package:sharedinbox/core/repositories/account_repository.dart'; @@ -294,6 +295,7 @@ class _AccountSync implements _SyncLoop { bool _isPermanentError(Object e) { if (isTlsConfigError(e)) return true; + if (e is MissingPluginException) return true; final s = e.toString().toLowerCase(); // enough_mail doesn't always have typed exceptions for auth, so we check strings. return s.contains('invalid credentials') || @@ -546,6 +548,7 @@ class _JmapAccountSync implements _SyncLoop { bool _isPermanentError(Object e) { if (isTlsConfigError(e)) return true; + if (e is MissingPluginException) return true; final s = e.toString().toLowerCase(); return s.contains('invalid credentials') || s.contains('authentication failed') || diff --git a/scripts/agent_loop.py b/scripts/agent_loop.py index c63eaec..26c3845 100755 --- a/scripts/agent_loop.py +++ b/scripts/agent_loop.py @@ -31,6 +31,7 @@ To resume the Claude conversation, look up the session UUID first: import argparse import json import os +import re import shlex import subprocess import sys @@ -188,6 +189,40 @@ def _find_pr_for_branch(branch: str, state: str = "open") -> dict | None: return None +def _open_issue_prs() -> list[dict]: + """Return all open PRs with issue-{N}-fix branches, oldest-first.""" + result = subprocess.run( + ["fgj", "--hostname", "codeberg.org", "pr", "list", + "--repo", REPO, "--state", "open", "--json"], + capture_output=True, text=True, + ) + if result.returncode != 0 or not result.stdout.strip(): + return [] + prs = json.loads(result.stdout) + issue_prs = [] + for pr in prs: + head = pr.get("head", {}) + ref = head.get("ref") or head.get("label", "").split(":")[-1] + if re.match(r"^issue-\d+-fix$", ref or ""): + issue_prs.append(pr) + issue_prs.sort(key=lambda p: p["number"]) + return issue_prs + + +def _latest_ci_run_for_pr(pr_number: int) -> dict | None: + """Return the latest CI run triggered by a pull_request event for the given PR number.""" + data = _tea_get(f"repos/{REPO}/actions/runs?event=pull_request&limit=50") + runs = (data or {}).get("workflow_runs", []) + for run in runs: + try: + payload = json.loads(run.get("event_payload", "{}")) + if payload.get("pull_request", {}).get("number") == pr_number: + return run + except (json.JSONDecodeError, AttributeError): + pass + return None + + def _merge_pr(pr_number: int) -> None: """Squash-merge a PR via fgj.""" _fgj("pr", "merge", str(pr_number), "--repo", REPO, "--merge-method", "squash") @@ -538,6 +573,38 @@ def _run_loop() -> int: ) return 0 + # ── 2b. Catch-up: scan open issue-N-fix PRs orphaned by a cleared state ───── + # This handles PRs whose CI has passed but were never merged because the + # state file was cleared (loop restart, killed agent, manual intervention). + open_prs = _open_issue_prs() + for pr in open_prs: + pr_number = pr["number"] + pr_url = f"{REPO_URL}/pulls/{pr_number}" + head = pr.get("head", {}) + branch = head.get("ref") or head.get("label", "").split(":")[-1] + m = re.match(r"^issue-(\d+)-fix$", branch or "") + issue_num = int(m.group(1)) if m else None + pr_run = _latest_ci_run_for_pr(pr_number) + + if pr_run and pr_run.get("status") == "running": + print(f"Catch-up: CI {_ci_run_url(pr_run['id'])} on PR #{pr_number} still running. Waiting.") + _write_state(None, issue_num, "pending-ci") + return 0 + + if pr_run and pr_run.get("status") in ("failure", "error"): + print(f"Catch-up: CI {_ci_run_url(pr_run['id'])} on PR #{pr_number} failed — skipping.") + continue + + if pr_run and pr_run.get("status") == "success": + print(f"Catch-up: CI passed on PR #{pr_number} ({pr_url}) — merging.") + _merge_pr(pr_number) + if issue_num: + _close_issue(issue_num) + print(f"Merged PR #{pr_number} and closed issue #{issue_num}.") + else: + print(f"Merged PR #{pr_number}.") + return 0 + # ── 3. Global CI check (agent pushed to main, or no pending issue) ──────── run = _latest_ci_run() diff --git a/test/unit/account_sync_manager_test.dart b/test/unit/account_sync_manager_test.dart index 55371a5..d053583 100644 --- a/test/unit/account_sync_manager_test.dart +++ b/test/unit/account_sync_manager_test.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'package:flutter/services.dart' show MissingPluginException; import 'package:mockito/annotations.dart'; +import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/mailbox.dart'; import 'package:sharedinbox/core/repositories/account_repository.dart'; @@ -30,6 +32,40 @@ void main() { // This is hard to test without real loops, but we can verify it doesn't crash. manager.syncNow('unknown'); }); + + // Regression test for issue #200: when flutter_secure_storage throws + // MissingPluginException (channel unavailable on the device), the IMAP sync + // loop must stop permanently instead of retrying indefinitely with backoff. + test( + 'MissingPluginException from secure storage stops IMAP sync loop permanently', + () async { + final syncLog = FakeSyncLogRepository(); + + final m = AccountSyncManager( + _AccountRepositoryWithMissingPlugin(), + FakeMailboxRepositoryWithInbox(), + FakeEmailRepository(), + syncLog: syncLog, + ); + + m.start(); + + // Allow the first sync cycle to run and fail. + await Future.delayed(const Duration(milliseconds: 100)); + + expect(syncLog.logs, hasLength(1)); + expect(syncLog.logs.first.success, isFalse); + + // Kicking the loop should have no effect once it has stopped permanently. + m.syncNow('1'); + await Future.delayed(const Duration(milliseconds: 100)); + + // Before the fix: kick triggers a retry → 2 log entries. + // After the fix: loop is permanently stopped → still exactly 1 entry. + expect(syncLog.logs, hasLength(1)); + + m.dispose(); + }); } class FakeEmailRepository implements EmailRepository { @@ -187,3 +223,34 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository { @override Future clearForResync(String accountId) async {} } + +class _AccountRepositoryWithMissingPlugin implements AccountRepository { + static const _account = Account( + id: '1', + displayName: 'Test', + email: 'test@example.com', + ); + + @override + Stream> observeAccounts() => Stream.value([_account]); + + @override + Future getAccount(String id) async => _account; + + @override + Future getPassword(String accountId) => Future.error( + MissingPluginException( + 'No implementation found for method read on channel ' + 'plugins.it.nomads.com/flutter_secure_storage', + ), + ); + + @override + Future addAccount(Account account, String password) async {} + + @override + Future updateAccount(Account account, {String? password}) async {} + + @override + Future removeAccount(String id) async {} +}