diff --git a/lib/src/issue_assistant_app.dart b/lib/src/issue_assistant_app.dart index adf27ff..e3f9d08 100644 --- a/lib/src/issue_assistant_app.dart +++ b/lib/src/issue_assistant_app.dart @@ -177,9 +177,11 @@ class IssueAssistantApp { 'poll tick source=$source projects=${projects.map((p) => p.key).join(",")} ' 'duration_ms=${stopwatch.elapsedMilliseconds} error=$error', ); - _cancelScheduler(); - if (!(_runCompleter?.isCompleted ?? true)) { - _runCompleter!.completeError(error, stackTrace); + if (_runCompleter != null) { + _logError( + 'poll tick source=$source will_retry=true ' + 'next_run=scheduled stack_trace=$stackTrace', + ); } } finally { for (final state in dueStates) { diff --git a/test/issue_assistant_app_test.dart b/test/issue_assistant_app_test.dart index c57e5ba..a72cdd6 100644 --- a/test/issue_assistant_app_test.dart +++ b/test/issue_assistant_app_test.dart @@ -1580,4 +1580,122 @@ projects: ); }); }); + + /// ```gherkin + /// Feature: Continuous issue polling + /// + /// As a maintainer running the long-lived poller + /// I want transient tracker network failures to be logged without stopping the process + /// So that the assistant can recover automatically when connectivity returns + /// ``` + group('IssueAssistantApp.run', () { + /// ```gherkin + /// Scenario: Continue polling after a transient GitHub search failure + /// Given a temporary project checkout that can be used as the local repo path + /// And a gh script that fails the first search request and succeeds afterwards + /// And an orchestrator-style config with a short poll interval + /// When the long-running app starts and enough time passes for a retry + /// Then the app logs the failed poll without stopping entirely + /// And it performs another search request after the transient failure + /// ``` + test('Continue polling after a transient GitHub search failure', () async { + // Given a temporary project checkout that can be used as the local repo path. + final sandbox = await Directory.systemTemp.createTemp('cws-run-retry-'); + final repoDir = await Directory(p.join(sandbox.path, 'repo')).create(); + await initGitRepo(repoDir); + + // And a gh script that fails the first search request and succeeds afterwards. + final ghScript = File(p.join(sandbox.path, 'gh')); + final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl')); + final failureState = File(p.join(sandbox.path, 'search.failed.once')); + await writeFlakySearchGhScript( + ghScript: ghScript, + failureStateFile: failureState, + issueListResponse: [ + { + 'number': 81, + 'title': 'Recovered issue polling', + 'body': 'Retry should succeed.', + 'state': 'open', + 'html_url': 'https://example.test/issues/81', + 'updated_at': '2026-04-05T13:45:00Z', + 'user': {'login': 'reporter'}, + 'labels': const [], + }, + ], + commentResponses: { + 81: [ + { + 'id': 810, + 'body': 'connectivity recovered', + 'html_url': 'https://example.test/issues/81#issuecomment-810', + 'created_at': '2026-04-05T13:45:00Z', + 'updated_at': '2026-04-05T13:45:00Z', + 'user': {'login': 'reporter'}, + }, + ], + }, + ghLog: ghLog, + ); + + // And an orchestrator-style config with a short poll interval. + final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml')); + await configFile.writeAsString(''' +defaults: + issueAssistant: + enabled: true + pollInterval: 1s +projects: + sample: + repo: owner/sample + path: ${repoDir.path} +'''); + final app = await IssueAssistantApp.open( + config: await AppConfig.load(configFile.path), + databasePath: p.join(sandbox.path, 'state.sqlite3'), + ghCommand: ghScript.path, + ); + final logMessages = []; + final previousLevel = Logger.root.level; + Logger.root.level = Level.INFO; + final subscription = Logger.root.onRecord.listen((record) { + logMessages.add(record.message); + }); + + // When the long-running app starts and enough time passes for a retry. + final runFuture = app.run(); + await Future.delayed(const Duration(milliseconds: 2600)); + await app.close(); + await runFuture; + await subscription.cancel(); + Logger.root.level = previousLevel; + + // Then the app logs the failed poll without stopping entirely. + expect( + logMessages.any( + (message) => + message.contains('poll tick source=startup') && + message.contains('error=ProcessException'), + ), + isTrue, + ); + expect( + logMessages.any((message) => message.contains('will_retry=true')), + isTrue, + ); + + // And it performs another search request after the transient failure. + final logLines = await ghLog.readAsLines(); + expect( + logLines + .where( + (line) => + line == + 'api -X GET search/issues -f q=repo:owner/sample is:issue state:open -f sort=updated -f order=asc -f per_page=100 -f page=1', + ) + .length, + greaterThanOrEqualTo(2), + ); + }); + }); } diff --git a/test/test_support.dart b/test/test_support.dart index 926f28b..826f490 100644 --- a/test/test_support.dart +++ b/test/test_support.dart @@ -182,6 +182,83 @@ Future writeFakeGhScript({ await Process.run('chmod', ['+x', ghScript.path]); } +Future writeFlakySearchGhScript({ + required File ghScript, + required File failureStateFile, + required List> issueListResponse, + required Map>> commentResponses, + File? ghLog, +}) async { + final searchItems = issueListResponse + .map( + (issue) => { + ...issue, + 'repository_url': 'https://api.github.com/repos/owner/sample', + }, + ) + .toList(growable: false); + final searchResponse = jsonEncode({ + 'total_count': searchItems.length, + 'incomplete_results': false, + 'items': searchItems, + }); + + final buffer = StringBuffer() + ..writeln('#!/usr/bin/env bash') + ..writeln('set -euo pipefail'); + + if (ghLog != null) { + buffer.writeln( + r'''printf '%s\n' "$*" >> ''' + '"${ghLog.path}"', + ); + } + + buffer + ..writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then''') + ..writeln(" cat <<'JSON'") + ..writeln(jsonEncode({'login': 'test-user'})) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + + buffer + ..writeln(r'''if [[ "$1" == "api" && "$4" == "search/issues" ]]; then''') + ..writeln(' if [[ ! -f "${failureStateFile.path}" ]]; then') + ..writeln(' touch "${failureStateFile.path}"') + ..writeln(r''' echo "error connecting to api.github.com" >&2''') + ..writeln( + r''' echo "check your internet connection or https://githubstatus.com" >&2''', + ) + ..writeln(' exit 1') + ..writeln(r''' fi''') + ..writeln(" cat <<'JSON'") + ..writeln(searchResponse) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + + for (final entry in commentResponses.entries) { + buffer + ..writeln( + 'if [[ "\$1" == "api" && "\$4" == ' + '"repos/owner/sample/issues/${entry.key}/comments?per_page=100" ]]; then', + ) + ..writeln(" cat <<'JSON'") + ..writeln(jsonEncode(entry.value)) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + } + + buffer + ..writeln(r'''echo "unexpected gh args: $*" >&2''') + ..writeln('exit 1'); + + await ghScript.writeAsString(buffer.toString()); + await Process.run('chmod', ['+x', ghScript.path]); +} + Future writeFakeTeaScript({ required File teaScript, required Map>> issueListResponsesByRepo,