feat: update issue assistant configuration to support max concurrent issues and reduce poll interval

This commit is contained in:
insleker 2026-04-05 00:33:42 +08:00
parent 8469ed00e0
commit eab1a3ffe8
6 changed files with 654 additions and 161 deletions

View File

@ -8,7 +8,8 @@ worktreeDir: ~/.worktrees
defaults: defaults:
issueAssistant: issueAssistant:
enabled: true enabled: true
pollInterval: 5m pollInterval: 30s
maxConcurrentIssues: 3
mentionTriggers: ["@codex", "@helper"] mentionTriggers: ["@codex", "@helper"]
commentPrefixTemplate: | commentPrefixTemplate: |
> [!NOTE] > [!NOTE]

View File

@ -254,6 +254,7 @@ class IssueAssistantConfig {
IssueAssistantConfig({ IssueAssistantConfig({
required this.enabled, required this.enabled,
required this.pollInterval, required this.pollInterval,
required this.maxConcurrentIssues,
required this.mentionTriggers, required this.mentionTriggers,
required this.prompt, required this.prompt,
required this.responders, required this.responders,
@ -265,6 +266,7 @@ class IssueAssistantConfig {
final bool enabled; final bool enabled;
final Duration pollInterval; final Duration pollInterval;
final int maxConcurrentIssues;
final List<String> mentionTriggers; final List<String> mentionTriggers;
final String prompt; final String prompt;
final List<CliResponderConfig> responders; final List<CliResponderConfig> responders;
@ -287,10 +289,16 @@ class IssueAssistantConfig {
.map((item) => AssistantCapability.parse(item.toString())) .map((item) => AssistantCapability.parse(item.toString()))
.toSet() .toSet()
: {AssistantCapability.planning, AssistantCapability.bugVerification}; : {AssistantCapability.planning, AssistantCapability.bugVerification};
final maxConcurrentIssues = _parsePositiveInt(
map['maxConcurrentIssues'],
fieldName: 'maxConcurrentIssues',
defaultValue: 3,
);
return IssueAssistantConfig( return IssueAssistantConfig(
enabled: map['enabled'] as bool? ?? true, enabled: map['enabled'] as bool? ?? true,
pollInterval: _parseDuration(map['pollInterval']?.toString() ?? '5m'), pollInterval: _parseDuration(map['pollInterval']?.toString() ?? '30s'),
maxConcurrentIssues: maxConcurrentIssues,
mentionTriggers: mentionTriggers:
(map['mentionTriggers'] as List?) (map['mentionTriggers'] as List?)
?.map((item) => item.toString()) ?.map((item) => item.toString())
@ -324,6 +332,23 @@ class IssueAssistantConfig {
}; };
} }
static int _parsePositiveInt(
Object? value, {
required String fieldName,
required int defaultValue,
}) {
if (value == null) {
return defaultValue;
}
final parsed = int.tryParse(value.toString());
if (parsed == null || parsed < 1) {
throw FormatException(
'$fieldName must be an integer greater than or equal to 1.',
);
}
return parsed;
}
static const String _defaultPrompt = ''' static const String _defaultPrompt = '''
You are a GitHub issue thread assistant. You are a GitHub issue thread assistant.

View File

@ -27,6 +27,11 @@ class IssueAssistantApp {
final GitHubClient githubClient; final GitHubClient githubClient;
final CliResponderRunner responderRunner; final CliResponderRunner responderRunner;
final WorkspaceManager workspaceManager; final WorkspaceManager workspaceManager;
final Map<String, _ProjectPollState> _projectPollStates = {};
final Set<Future<void>> _activePolls = <Future<void>>{};
Completer<void>? _runCompleter;
bool _isClosing = false;
bool _isClosed = false;
static Future<IssueAssistantApp> open({ static Future<IssueAssistantApp> open({
required AppConfig config, required AppConfig config,
@ -44,9 +49,39 @@ class IssueAssistantApp {
} }
Future<void> run() async { Future<void> run() async {
while (true) { if (_isClosed) {
await runOnce(); throw StateError('IssueAssistantApp is already closed.');
await Future<void>.delayed(_smallestPollInterval()); }
if (_runCompleter != null) {
return _runCompleter!.future;
}
final enabledProjects = config.projects.values
.where((project) => project.issueAssistant.enabled)
.toList(growable: false);
if (enabledProjects.isEmpty) {
_log('no enabled projects configured');
return;
}
final completer = Completer<void>();
_runCompleter = completer;
for (final project in enabledProjects) {
final state = _projectPollStates.putIfAbsent(
project.key,
() => _ProjectPollState(project: project),
);
unawaited(_triggerProjectPoll(project, source: 'startup'));
state.timer = Timer.periodic(project.issueAssistant.pollInterval, (_) {
unawaited(_triggerProjectPoll(project, source: 'interval'));
});
}
try {
await completer.future;
} finally {
_cancelProjectPollTimers();
} }
} }
@ -62,16 +97,83 @@ class IssueAssistantApp {
_log('poll cycle completed'); _log('poll cycle completed');
} }
Future<void> close() => database.close(); Future<void> close() async {
if (_isClosed || _isClosing) {
return;
}
Duration _smallestPollInterval() { _isClosing = true;
return config.projects.values _cancelProjectPollTimers();
.where((project) => project.issueAssistant.enabled) _completeRunIfPending();
.map((project) => project.issueAssistant.pollInterval) await Future.wait(_activePolls.toList(growable: false));
.fold<Duration>( await database.close();
const Duration(minutes: 5), _isClosed = true;
(current, next) => next < current ? next : current, _isClosing = false;
}
Future<void> _triggerProjectPoll(
ProjectConfig project, {
required String source,
}) async {
if (_isClosing || _isClosed) {
return;
}
final state = _projectPollStates.putIfAbsent(
project.key,
() => _ProjectPollState(project: project),
);
if (state.isRunning) {
_log(
'skip poll project=${project.key} reason=already_running source=$source',
);
return;
}
late final Future<void> pollFuture;
pollFuture = () async {
state.isRunning = true;
final stopwatch = Stopwatch()..start();
_log('poll tick project=${project.key} source=$source');
try {
await _pollProject(project);
stopwatch.stop();
_log(
'poll tick project=${project.key} source=$source '
'duration_ms=${stopwatch.elapsedMilliseconds}',
); );
} catch (error, stackTrace) {
stopwatch.stop();
_logError(
'poll tick project=${project.key} source=$source '
'duration_ms=${stopwatch.elapsedMilliseconds} error=$error',
);
_cancelProjectPollTimers();
if (!(_runCompleter?.isCompleted ?? true)) {
_runCompleter!.completeError(error, stackTrace);
}
} finally {
state.isRunning = false;
_activePolls.remove(pollFuture);
}
}();
_activePolls.add(pollFuture);
await pollFuture;
}
void _cancelProjectPollTimers() {
for (final state in _projectPollStates.values) {
state.timer?.cancel();
state.timer = null;
}
}
void _completeRunIfPending() {
final completer = _runCompleter;
if (completer != null && !completer.isCompleted) {
completer.complete();
}
} }
Future<void> _pollProject(ProjectConfig project) async { Future<void> _pollProject(ProjectConfig project) async {
@ -94,13 +196,21 @@ class IssueAssistantApp {
); );
_log('project=${project.key} fetched_issues=${issues.length}'); _log('project=${project.key} fetched_issues=${issues.length}');
DateTime? latestSeen = since; final latestSeen = issues.map((issue) => issue.updatedAt).fold<DateTime?>(
for (final issue in issues) { since,
if (latestSeen == null || issue.updatedAt.isAfter(latestSeen)) { (current, next) {
latestSeen = issue.updatedAt; if (current == null || next.isAfter(current)) {
} return next;
await _processIssue(project, issue); }
} return current;
},
);
_log(
'project=${project.key} issue_queue=${issues.length} '
'max_concurrent=${project.issueAssistant.maxConcurrentIssues}',
);
await _processIssuesConcurrently(project, issues);
await database.upsertProjectState( await database.upsertProjectState(
projectKey: project.key, projectKey: project.key,
@ -123,6 +233,41 @@ class IssueAssistantApp {
} }
} }
Future<void> _processIssuesConcurrently(
ProjectConfig project,
List<GitHubIssueSummary> issues,
) async {
if (issues.isEmpty) {
return;
}
var nextIndex = 0;
Future<void> worker() async {
while (true) {
final currentIndex = nextIndex;
if (currentIndex >= issues.length) {
return;
}
nextIndex += 1;
final activeCount =
currentIndex + 1 <= project.issueAssistant.maxConcurrentIssues
? currentIndex + 1
: project.issueAssistant.maxConcurrentIssues;
_log(
'project=${project.key} process_issue issue=${issues[currentIndex].number} '
'queue_index=${currentIndex + 1}/${issues.length} active_limit=$activeCount',
);
await _processIssue(project, issues[currentIndex]);
}
}
final workerCount =
issues.length < project.issueAssistant.maxConcurrentIssues
? issues.length
: project.issueAssistant.maxConcurrentIssues;
await Future.wait([for (var i = 0; i < workerCount; i += 1) worker()]);
}
Future<void> _processIssue( Future<void> _processIssue(
ProjectConfig project, ProjectConfig project,
GitHubIssueSummary issue, GitHubIssueSummary issue,
@ -192,93 +337,98 @@ class IssueAssistantApp {
} }
ResponderResult? selectedResult; ResponderResult? selectedResult;
String? workspacePath;
Object? lastError; Object? lastError;
final needsWorktree = _looksLikeBugVerification(thread);
final workspacePath = await workspaceManager.createEphemeralWorktree(
project.path,
);
_log(
'issue=${project.key}#${issue.number} workspace_mode='
'worktree workspace=$workspacePath',
);
for (final responder in project.issueAssistant.responders) { try {
final needsWorktree = _looksLikeBugVerification(thread); for (final responder in project.issueAssistant.responders) {
workspacePath = await workspaceManager.createEphemeralWorktree(
project.path,
);
_log(
'run responder=${responder.id} issue=${project.key}#${issue.number} '
'mode_hint=${needsWorktree ? "bug_verification" : "planning"} '
'workspace=$workspacePath',
);
try {
final result = await responderRunner.run(
responder: responder,
project: project,
thread: thread,
prompt: prompt,
forceReply: forceReply,
workspacePath: workspacePath,
);
await database.addExecutionRun(
jobId: jobId,
responderId: result.responderId,
mode: result.mode,
decision: result.decision,
success: true,
workspacePath: workspacePath,
stdout: result.rawStdout,
stderr: result.rawStderr,
);
_logResponderOutput(
responderId: responder.id,
issueKey: '${project.key}#${issue.number}',
stdout: result.rawStdout,
stderr: result.rawStderr,
);
_log( _log(
'responder=${responder.id} issue=${project.key}#${issue.number}, ' 'run responder=${responder.id} issue=${project.key}#${issue.number} '
'title="${thread.issue.title}", ' 'mode_hint=${needsWorktree ? "bug_verification" : "planning"} '
'decision=${result.decision}, mode=${result.mode}', 'workspace=$workspacePath',
); );
selectedResult = forceReply && !result.shouldReply
? result.forceReply() try {
: result; final result = await responderRunner.run(
if (forceReply && !result.shouldReply) { responder: responder,
project: project,
thread: thread,
prompt: prompt,
forceReply: forceReply,
workspacePath: workspacePath,
);
await database.addExecutionRun(
jobId: jobId,
responderId: result.responderId,
mode: result.mode,
decision: result.decision,
success: true,
workspacePath: workspacePath,
stdout: result.rawStdout,
stderr: result.rawStderr,
);
_logResponderOutput(
responderId: responder.id,
issueKey: '${project.key}#${issue.number}',
stdout: result.rawStdout,
stderr: result.rawStderr,
);
_log(
'responder=${responder.id} issue=${project.key}#${issue.number}, '
'title="${thread.issue.title}", '
'decision=${result.decision}, mode=${result.mode}',
);
selectedResult = forceReply && !result.shouldReply
? result.forceReply()
: result;
if (forceReply && !result.shouldReply) {
_log(
'responder=${responder.id} issue=${project.key}#${issue.number} '
'override=force_reply original_decision=${result.decision}',
);
}
break;
} catch (error) {
lastError = error;
final rawStdout = switch (error) {
CliResponderExecutionException(:final rawStdout) => rawStdout,
_ => '',
};
final rawStderr = switch (error) {
CliResponderExecutionException(:final rawStderr) => rawStderr,
_ => error.toString(),
};
_logResponderOutput(
responderId: responder.id,
issueKey: '${project.key}#${issue.number}',
stdout: rawStdout,
stderr: rawStderr,
);
_log( _log(
'responder=${responder.id} issue=${project.key}#${issue.number} ' 'responder=${responder.id} issue=${project.key}#${issue.number} '
'override=force_reply original_decision=${result.decision}', 'failed error=$error',
);
await database.addExecutionRun(
jobId: jobId,
responderId: responder.id,
mode: 'error',
decision: 'no_reply',
success: false,
workspacePath: workspacePath,
stdout: rawStdout,
stderr: rawStderr,
); );
} }
break;
} catch (error) {
lastError = error;
final rawStdout = switch (error) {
CliResponderExecutionException(:final rawStdout) => rawStdout,
_ => '',
};
final rawStderr = switch (error) {
CliResponderExecutionException(:final rawStderr) => rawStderr,
_ => error.toString(),
};
_logResponderOutput(
responderId: responder.id,
issueKey: '${project.key}#${issue.number}',
stdout: rawStdout,
stderr: rawStderr,
);
_log(
'responder=${responder.id} issue=${project.key}#${issue.number} '
'failed error=$error',
);
await database.addExecutionRun(
jobId: jobId,
responderId: responder.id,
mode: 'error',
decision: 'no_reply',
success: false,
workspacePath: workspacePath,
stdout: rawStdout,
stderr: rawStderr,
);
} finally {
await workspaceManager.disposeEphemeralWorktree(workspacePath);
} }
} finally {
await workspaceManager.disposeEphemeralWorktree(workspacePath);
} }
if (selectedResult == null) { if (selectedResult == null) {
@ -472,3 +622,11 @@ class IssueAssistantApp {
} }
} }
} }
class _ProjectPollState {
_ProjectPollState({required this.project});
final ProjectConfig project;
Timer? timer;
bool isRunning = false;
}

View File

@ -99,6 +99,11 @@ projects:
config.projects['sample']!.issueAssistant.commentFooterTemplate, config.projects['sample']!.issueAssistant.commentFooterTemplate,
defaultCommentFooterTemplate, defaultCommentFooterTemplate,
); );
expect(
config.projects['sample']!.issueAssistant.pollInterval,
const Duration(seconds: 30),
);
expect(config.projects['sample']!.issueAssistant.maxConcurrentIssues, 3);
}); });
/// ```gherkin /// ```gherkin

View File

@ -322,9 +322,9 @@ projects:
}); });
/// ```gherkin /// ```gherkin
/// Scenario: Run mention replies from an ephemeral worktree with issue context /// Scenario: Run bug verification mention replies from an ephemeral worktree with issue context
/// Given a temporary project checkout that can be used as the local repo path /// Given a temporary project checkout that can be used as the local repo path
/// And GitHub issue data containing an explicit assistant mention /// And GitHub issue data containing a bug verification mention
/// And a responder that records its working directory and stdin before returning no_reply /// And a responder that records its working directory and stdin before returning no_reply
/// And an orchestrator-style config pointing to the fake repo and responder /// And an orchestrator-style config pointing to the fake repo and responder
/// When the app processes one polling cycle /// When the app processes one polling cycle
@ -332,66 +332,192 @@ projects:
/// And the responder stdin includes the latest issue comment text /// And the responder stdin includes the latest issue comment text
/// And the app still posts a GitHub comment because the thread explicitly mentioned the assistant /// And the app still posts a GitHub comment because the thread explicitly mentioned the assistant
/// ``` /// ```
test('Run mention replies from an ephemeral worktree with issue context', () async { test(
// Given a temporary project checkout that can be used as the local repo path. 'Run bug verification mention replies from an ephemeral worktree with issue context',
final sandbox = await Directory.systemTemp.createTemp( () async {
'cws-mention-worktree-', // Given a temporary project checkout that can be used as the local repo path.
); final sandbox = await Directory.systemTemp.createTemp(
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create(); 'cws-mention-worktree-',
await initGitRepo(repoDir); );
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
await initGitRepo(repoDir);
// And GitHub issue data containing an explicit assistant mention. // And GitHub issue data containing a bug verification mention.
final ghScript = File(p.join(sandbox.path, 'gh')); final ghScript = File(p.join(sandbox.path, 'gh'));
final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl')); final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
final issueData = [ final issueData = [
{ {
'number': 24, 'number': 24,
'title': 'Need your thoughts', 'title': 'Need help verifying a bug',
'body': 'Please review the CLI workflow.', 'body': 'Please verify and reproduce this bug in the CLI workflow.',
'state': 'open', 'state': 'open',
'html_url': 'https://example.test/issues/24', 'html_url': 'https://example.test/issues/24',
'updated_at': '2026-04-04T15:30:00Z', 'updated_at': '2026-04-04T15:30:00Z',
'user': {'login': 'reporter'}, 'user': {'login': 'reporter'},
'labels': const [], 'labels': const [],
}, },
]; ];
final commentsData = [ final commentsData = [
{ {
'id': 240, 'id': 240,
'body': '@helper give me your though about this CLI flow', 'body': '@helper please verify and reproduce this bug',
'html_url': 'https://example.test/issues/24#issuecomment-240', 'html_url': 'https://example.test/issues/24#issuecomment-240',
'created_at': '2026-04-04T15:30:00Z', 'created_at': '2026-04-04T15:30:00Z',
'updated_at': '2026-04-04T15:30:00Z', 'updated_at': '2026-04-04T15:30:00Z',
'user': {'login': 'reporter'}, 'user': {'login': 'reporter'},
}, },
]; ];
await writeFakeGhScript( await writeFakeGhScript(
ghScript: ghScript, ghScript: ghScript,
issueListResponse: issueData, issueListResponse: issueData,
commentResponses: {24: commentsData}, commentResponses: {24: commentsData},
ghLog: ghLog, ghLog: ghLog,
postCommentIssueNumber: 24, postCommentIssueNumber: 24,
); );
// And a responder that records its working directory and stdin before returning no_reply. // And a responder that records its working directory and stdin before returning no_reply.
final cwdCapture = File(p.join(sandbox.path, 'responder.cwd')); final cwdCapture = File(p.join(sandbox.path, 'responder.cwd'));
final stdinCapture = File(p.join(sandbox.path, 'responder.stdin')); final stdinCapture = File(p.join(sandbox.path, 'responder.stdin'));
final responderScript = File( final responderScript = File(
p.join(sandbox.path, 'responder-capture.sh'), p.join(sandbox.path, 'responder-capture.sh'),
); );
await responderScript.writeAsString('''#!/usr/bin/env bash await responderScript.writeAsString('''#!/usr/bin/env bash
set -euo pipefail set -euo pipefail
pwd > "${cwdCapture.path}" pwd > "${cwdCapture.path}"
cat > "${stdinCapture.path}" cat > "${stdinCapture.path}"
cat <<'JSON' cat <<'JSON'
{"decision":"no_reply","mode":"planning","summary":"captured mention context"} {"decision":"no_reply","mode":"planning","summary":"captured mention context"}
JSON JSON
''');
await Process.run('chmod', ['+x', responderScript.path]);
// And an orchestrator-style config pointing to the fake repo and responder.
final configFile = File(
p.join(sandbox.path, 'agent-orchestrator.yaml'),
);
await configFile.writeAsString('''
defaults:
issueAssistant:
enabled: true
pollInterval: 5m
mentionTriggers: ["@helper"]
responders:
- id: primary
command: ${responderScript.path}
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,
);
// When the app processes one polling cycle.
await app.runOnce();
await app.close();
// Then the responder runs from an ephemeral worktree instead of the source checkout.
final capturedCwd = await cwdCapture.readAsString();
expect(
p.normalize(capturedCwd.trim()),
isNot(p.normalize(repoDir.path)),
);
// And the responder stdin includes the latest issue comment text.
final capturedStdin = await stdinCapture.readAsString();
expect(
capturedStdin,
contains('@helper please verify and reproduce this bug'),
);
// And the app still posts a GitHub comment because the thread explicitly mentioned the assistant.
final logLines = await ghLog.readAsLines();
expect(
logLines
.where(
(line) =>
line.contains('repos/owner/sample/issues/24/comments'),
)
.length,
2,
);
},
);
/// ```gherkin
/// Scenario: Run planning replies from an ephemeral worktree
/// Given a temporary project checkout that can be used as the local repo path
/// And GitHub issue data containing a planning mention
/// And a responder that records its working directory before returning a reply
/// And an orchestrator-style config pointing to the fake repo and responder
/// When the app processes one polling cycle
/// Then the responder runs from an ephemeral worktree instead of the source checkout
/// And the ephemeral worktree directory is cleaned up afterwards
/// ```
test('Run planning replies from an ephemeral worktree', () async {
// Given a temporary project checkout that can be used as the local repo path.
final sandbox = await Directory.systemTemp.createTemp(
'cws-planning-project-path-',
);
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
final worktreeRoot = await Directory(
p.join(sandbox.path, 'worktrees'),
).create();
await initGitRepo(repoDir);
// And GitHub issue data containing a planning mention.
final ghScript = File(p.join(sandbox.path, 'gh'));
final issueData = [
{
'number': 25,
'title': 'Need planning help',
'body': 'Please review the service layout.',
'state': 'open',
'html_url': 'https://example.test/issues/25',
'updated_at': '2026-04-04T15:35:00Z',
'user': {'login': 'reporter'},
'labels': const [],
},
];
final commentsData = [
{
'id': 250,
'body': '@helper can you plan the service layout?',
'html_url': 'https://example.test/issues/25#issuecomment-250',
'created_at': '2026-04-04T15:35:00Z',
'updated_at': '2026-04-04T15:35:00Z',
'user': {'login': 'reporter'},
},
];
await writeFakeGhScript(
ghScript: ghScript,
issueListResponse: issueData,
commentResponses: {25: commentsData},
postCommentIssueNumber: 25,
);
// And a responder that records its working directory before returning a reply.
final cwdCapture = File(p.join(sandbox.path, 'planning.cwd'));
final responderScript = File(
p.join(sandbox.path, 'planning-responder.sh'),
);
await responderScript.writeAsString('''#!/usr/bin/env bash
set -euo pipefail
pwd > "${cwdCapture.path}"
cat >/dev/null
cat <<'JSON'
{"decision":"reply","mode":"planning","markdown":"planning reply","summary":"used project path"}
JSON
'''); ''');
await Process.run('chmod', ['+x', responderScript.path]); await Process.run('chmod', ['+x', responderScript.path]);
// And an orchestrator-style config pointing to the fake repo and responder. // And an orchestrator-style config pointing to the fake repo and responder.
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml')); final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString(''' await configFile.writeAsString('''
worktreeDir: ${worktreeRoot.path}
defaults: defaults:
issueAssistant: issueAssistant:
enabled: true enabled: true
@ -418,24 +544,13 @@ projects:
// Then the responder runs from an ephemeral worktree instead of the source checkout. // Then the responder runs from an ephemeral worktree instead of the source checkout.
final capturedCwd = await cwdCapture.readAsString(); final capturedCwd = await cwdCapture.readAsString();
expect(p.normalize(capturedCwd.trim()), isNot(p.normalize(repoDir.path))); expect(p.normalize(capturedCwd.trim()), isNot(p.normalize(repoDir.path)));
// And the responder stdin includes the latest issue comment text.
final capturedStdin = await stdinCapture.readAsString();
expect( expect(
capturedStdin, p.isWithin(worktreeRoot.path, p.normalize(capturedCwd.trim())),
contains('@helper give me your though about this CLI flow'), isTrue,
); );
// And the app still posts a GitHub comment because the thread explicitly mentioned the assistant. // And the ephemeral worktree directory is cleaned up afterwards.
final logLines = await ghLog.readAsLines(); expect(await worktreeRoot.list().isEmpty, isTrue);
expect(
logLines
.where(
(line) => line.contains('repos/owner/sample/issues/24/comments'),
)
.length,
2,
);
}); });
/// ```gherkin /// ```gherkin
@ -662,6 +777,176 @@ projects:
); );
}); });
/// ```gherkin
/// Scenario: Process multiple issues concurrently up to the configured limit
/// Given a temporary project checkout that can be used as the local repo path
/// And GitHub issue data containing three planning mentions
/// And a responder that sleeps for one second while logging each issue number
/// And an orchestrator-style config with maxConcurrentIssues set to three
/// When the app processes one polling cycle
/// Then the cycle completes in under three seconds
/// And all three issues still receive replies
/// ```
test('Process multiple issues concurrently up to the configured limit', () async {
// Given a temporary project checkout that can be used as the local repo path.
final sandbox = await Directory.systemTemp.createTemp(
'cws-concurrent-issues-',
);
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
await initGitRepo(repoDir);
// And GitHub issue data containing three planning mentions.
final ghScript = File(p.join(sandbox.path, 'gh'));
final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
final issueData = [
{
'number': 41,
'title': 'First planning request',
'body': 'first',
'state': 'open',
'html_url': 'https://example.test/issues/41',
'updated_at': '2026-04-04T16:20:00Z',
'user': {'login': 'reporter'},
'labels': const [],
},
{
'number': 42,
'title': 'Second planning request',
'body': 'second',
'state': 'open',
'html_url': 'https://example.test/issues/42',
'updated_at': '2026-04-04T16:21:00Z',
'user': {'login': 'reporter'},
'labels': const [],
},
{
'number': 43,
'title': 'Third planning request',
'body': 'third',
'state': 'open',
'html_url': 'https://example.test/issues/43',
'updated_at': '2026-04-04T16:22:00Z',
'user': {'login': 'reporter'},
'labels': const [],
},
];
await writeFakeGhScript(
ghScript: ghScript,
issueListResponse: issueData,
commentResponses: {
41: [
{
'id': 410,
'body': '@helper first',
'html_url': 'https://example.test/issues/41#issuecomment-410',
'created_at': '2026-04-04T16:20:00Z',
'updated_at': '2026-04-04T16:20:00Z',
'user': {'login': 'reporter'},
},
],
42: [
{
'id': 420,
'body': '@helper second',
'html_url': 'https://example.test/issues/42#issuecomment-420',
'created_at': '2026-04-04T16:21:00Z',
'updated_at': '2026-04-04T16:21:00Z',
'user': {'login': 'reporter'},
},
],
43: [
{
'id': 430,
'body': '@helper third',
'html_url': 'https://example.test/issues/43#issuecomment-430',
'created_at': '2026-04-04T16:22:00Z',
'updated_at': '2026-04-04T16:22:00Z',
'user': {'login': 'reporter'},
},
],
},
postCommentIssueNumbers: {41, 42, 43},
ghLog: ghLog,
);
// And a responder that sleeps for one second while logging each issue number.
final eventLog = File(p.join(sandbox.path, 'responder-events.log'));
final responderScript = File(p.join(sandbox.path, 'responder-slow.sh'));
await writeDelayedLoggingResponderScript(
responderScript,
eventLog: eventLog,
delay: const Duration(seconds: 1),
response: {
'decision': 'reply',
'mode': 'planning',
'markdown': 'parallel reply',
'summary': 'completed after delay',
},
);
// And an orchestrator-style config with maxConcurrentIssues set to three.
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
defaults:
issueAssistant:
enabled: true
pollInterval: 5m
maxConcurrentIssues: 3
mentionTriggers: ["@helper"]
responders:
- id: primary
command: ${responderScript.path}
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,
);
// When the app processes one polling cycle.
final stopwatch = Stopwatch()..start();
await app.runOnce();
stopwatch.stop();
await app.close();
// Then the cycle completes in under three seconds.
expect(stopwatch.elapsed, lessThan(const Duration(seconds: 3)));
final eventLines = await eventLog.readAsLines();
expect(eventLines.where((line) => line.startsWith('start:')).length, 3);
expect(eventLines.where((line) => line.startsWith('end:')).length, 3);
// And all three issues still receive replies.
final logLines = await ghLog.readAsLines();
expect(
logLines
.where(
(line) => line.contains('repos/owner/sample/issues/41/comments'),
)
.length,
2,
);
expect(
logLines
.where(
(line) => line.contains('repos/owner/sample/issues/42/comments'),
)
.length,
2,
);
expect(
logLines
.where(
(line) => line.contains('repos/owner/sample/issues/43/comments'),
)
.length,
2,
);
});
/// ```gherkin /// ```gherkin
/// Scenario: Fall back to unknown identity when gh user lookup fails /// Scenario: Fall back to unknown identity when gh user lookup fails
/// Given a temporary project checkout that can be used as the local repo path /// Given a temporary project checkout that can be used as the local repo path

View File

@ -142,6 +142,25 @@ JSON
await Process.run('chmod', ['+x', responderScript.path]); await Process.run('chmod', ['+x', responderScript.path]);
} }
Future<void> writeDelayedLoggingResponderScript(
File responderScript, {
required File eventLog,
required Duration delay,
required Map<String, Object?> response,
}) async {
await responderScript.writeAsString('''#!/usr/bin/env bash
set -euo pipefail
printf 'start:%s:%s\n' "\$CWS_ISSUE_NUMBER" "\$(date +%s)" >> "${eventLog.path}"
cat >/dev/null
sleep ${delay.inSeconds}
printf 'end:%s:%s\n' "\$CWS_ISSUE_NUMBER" "\$(date +%s)" >> "${eventLog.path}"
cat <<'JSON'
${jsonEncode(response)}
JSON
''');
await Process.run('chmod', ['+x', responderScript.path]);
}
Future<void> initGitRepo(Directory directory) async { Future<void> initGitRepo(Directory directory) async {
await runGit(['init'], workingDirectory: directory.path); await runGit(['init'], workingDirectory: directory.path);
await runGit([ await runGit([