feat: refactor channel reconciliation config for #21

This commit is contained in:
insleker 2026-04-08 22:10:08 +08:00
parent 64901a348a
commit 5d0fbcc7f9
11 changed files with 639 additions and 77 deletions

View File

@ -11,15 +11,32 @@ GitHub projects default to `gh/polling`, and Gitea projects default to
`gh/gosmee` or `gh/webhook-forward`, and Gitea projects can switch to `gh/gosmee` or `gh/webhook-forward`, and Gitea projects can switch to
`tea/gosmee`. `tea/gosmee`.
`gh/gosmee` creates a temporary GitHub webhook that targets a generated gosmee `gh/gosmee` creates a temporary GitHub webhook that targets a generated gosmee
URL, then keeps the same polling reconciliation path active for startup sync URL. `gh/webhook-forward` starts `gh webhook forward` for `issues` and
and missed-event recovery. `issue_comment` events. `tea/gosmee` creates a temporary Gitea webhook that
`gh/webhook-forward` starts targets a generated gosmee URL. Channel-based event sources always run one
`gh webhook forward` for `issues` and `issue_comment` events while keeping startup reconciliation poll. Set `reconciliation: continuous` plus
periodic polling reconciliation for startup sync and missed-event recovery. `reconcileInterval` to keep periodic reconciliation active for missed-event
`tea/gosmee` creates a temporary Gitea webhook that targets a generated gosmee recovery, or use `reconciliation: startup-only` to skip background polling
URL, then keeps the same polling reconciliation path active for startup sync after startup. `--once` still uses a single polling reconciliation cycle so
and missed-event recovery. `--once` still uses a single polling reconciliation existing backlog handling keeps working.
cycle so existing backlog handling keeps working.
Use a nested `issueAssistant.eventSource` object so source-specific fields stay
grouped with the selected source type. For example:
```yaml
issueAssistant:
eventSource:
type: gh/webhook-forward
reconciliation: continuous
reconcileInterval: 300s
```
```yaml
issueAssistant:
eventSource:
type: gh/polling
pollInterval: 30s
```
Generated comments include both a visible prefix and footer by default so it is Generated comments include both a visible prefix and footer by default so it is
clear the reply came from automation even when `gh` or `tea` is authenticated clear the reply came from automation even when `gh` or `tea` is authenticated

View File

@ -7,10 +7,14 @@ worktreeDir: ~/.worktrees
defaults: defaults:
issueAssistant: issueAssistant:
enabled: true enabled: true
# Use tea/gosmee for long-running runs while keeping --once and missed-event # Use tea/gosmee for long-running runs.
# recovery on polling reconciliation. eventSource:
# eventSource: tea/gosmee type: tea/polling
pollInterval: 30s pollInterval: 30s
# eventSource:
# type: tea/gosmee
# reconciliation: startup-only
# reconcileInterval: 30s
maxConcurrentIssues: 3 maxConcurrentIssues: 3
mentionTriggers: ["@codex", "@helper"] mentionTriggers: ["@codex", "@helper"]
responders: responders:

View File

@ -7,9 +7,14 @@ worktreeDir: ~/.worktrees
defaults: defaults:
issueAssistant: issueAssistant:
enabled: true enabled: true
# Use gh/gosmee for long-running runs while keeping --once on polling. # Use gh/gosmee or gh/webhook-forward for long-running runs.
# eventSource: gh/gosmee eventSource:
pollInterval: 30s type: gh/polling
pollInterval: 30s
# eventSource:
# type: gh/gosmee
# reconciliation: startup-only
# reconcileInterval: 30s
maxConcurrentIssues: 3 maxConcurrentIssues: 3
mentionTriggers: ["@codex", "@helper"] mentionTriggers: ["@codex", "@helper"]
commentPrefixTemplate: | commentPrefixTemplate: |

View File

@ -11,6 +11,7 @@ export 'config_schema.dart'
show show
AssistantCapability, AssistantCapability,
IssueAssistantEventSourceKind, IssueAssistantEventSourceKind,
IssueAssistantReconciliationKind,
IssueTrackerProvider, IssueTrackerProvider,
NotificationConfigDocument, NotificationConfigDocument,
NotificationEvent, NotificationEvent,
@ -83,6 +84,7 @@ class AppConfig {
} }
_assertCamelCaseConfigKeys(root); _assertCamelCaseConfigKeys(root);
_normalizeLegacyEventSourceConfig(root);
final document = _parseDocument(root); final document = _parseDocument(root);
return _fromDocument(path, document); return _fromDocument(path, document);
} }
@ -133,8 +135,10 @@ class AppConfig {
defaults: AppConfigDefaultsDocument( defaults: AppConfigDefaultsDocument(
issueAssistant: IssueAssistantConfigDocument( issueAssistant: IssueAssistantConfigDocument(
enabled: true, enabled: true,
eventSource: IssueAssistantEventSourceKind.ghPolling, eventSource: IssueAssistantEventSourceDocument(
pollInterval: '30s', type: IssueAssistantEventSourceKind.ghPolling,
pollInterval: '30s',
),
maxConcurrentIssues: 3, maxConcurrentIssues: 3,
mentionTriggers: const ['@codex', '@helper'], mentionTriggers: const ['@codex', '@helper'],
), ),
@ -192,22 +196,26 @@ class ProjectConfig {
final provider = document.provider ?? IssueTrackerProvider.github; final provider = document.provider ?? IssueTrackerProvider.github;
var issueAssistant = defaultAssistant.merge(document.issueAssistant); var issueAssistant = defaultAssistant.merge(document.issueAssistant);
if (document.issueAssistant?.eventSource == null && if (document.issueAssistant?.eventSource?.type == null &&
_isPollingEventSource(issueAssistant.eventSource)) { issueAssistant.eventSource is PollingIssueEventSourceConfig) {
issueAssistant = issueAssistant.copyWith( issueAssistant = issueAssistant.copyWith(
eventSource: provider == IssueTrackerProvider.github eventSource:
? IssueAssistantEventSourceKind.ghPolling (issueAssistant.eventSource as PollingIssueEventSourceConfig)
: IssueAssistantEventSourceKind.teaPolling, .copyWith(
type: provider == IssueTrackerProvider.github
? IssueAssistantEventSourceKind.ghPolling
: IssueAssistantEventSourceKind.teaPolling,
),
); );
} }
switch (issueAssistant.eventSource) { switch (issueAssistant.eventSource.type) {
case IssueAssistantEventSourceKind.ghPolling: case IssueAssistantEventSourceKind.ghPolling:
case IssueAssistantEventSourceKind.ghGosmee: case IssueAssistantEventSourceKind.ghGosmee:
case IssueAssistantEventSourceKind.ghWebhookForward: case IssueAssistantEventSourceKind.ghWebhookForward:
if (provider != IssueTrackerProvider.github) { if (provider != IssueTrackerProvider.github) {
throw FormatException( throw FormatException(
'projects.$key.issueAssistant.eventSource ' 'projects.$key.issueAssistant.eventSource '
'${_eventSourceConfigValue(issueAssistant.eventSource)} ' '${_eventSourceConfigValue(issueAssistant.eventSource.type)} '
'requires provider: github.', 'requires provider: github.',
); );
} }
@ -257,7 +265,6 @@ class IssueAssistantConfig {
IssueAssistantConfig({ IssueAssistantConfig({
required this.enabled, required this.enabled,
required this.eventSource, required this.eventSource,
required this.pollInterval,
required this.maxConcurrentIssues, required this.maxConcurrentIssues,
required this.mentionTriggers, required this.mentionTriggers,
required this.prompt, required this.prompt,
@ -270,8 +277,7 @@ class IssueAssistantConfig {
}); });
final bool enabled; final bool enabled;
final IssueAssistantEventSourceKind eventSource; final IssueEventSourceConfig eventSource;
final Duration pollInterval;
final int maxConcurrentIssues; final int maxConcurrentIssues;
final List<String> mentionTriggers; final List<String> mentionTriggers;
final String prompt; final String prompt;
@ -297,11 +303,10 @@ class IssueAssistantConfig {
); );
} }
IssueAssistantConfig copyWith({IssueAssistantEventSourceKind? eventSource}) { IssueAssistantConfig copyWith({IssueEventSourceConfig? eventSource}) {
return IssueAssistantConfig( return IssueAssistantConfig(
enabled: enabled, enabled: enabled,
eventSource: eventSource ?? this.eventSource, eventSource: eventSource ?? this.eventSource,
pollInterval: pollInterval,
maxConcurrentIssues: maxConcurrentIssues, maxConcurrentIssues: maxConcurrentIssues,
mentionTriggers: mentionTriggers, mentionTriggers: mentionTriggers,
prompt: prompt, prompt: prompt,
@ -315,6 +320,56 @@ class IssueAssistantConfig {
} }
} }
sealed class IssueEventSourceConfig {
const IssueEventSourceConfig({required this.type});
final IssueAssistantEventSourceKind type;
bool get usesChannelEventSource => !_isPollingEventSource(type);
}
class PollingIssueEventSourceConfig extends IssueEventSourceConfig {
const PollingIssueEventSourceConfig({
required super.type,
required this.pollInterval,
});
final Duration pollInterval;
PollingIssueEventSourceConfig copyWith({
IssueAssistantEventSourceKind? type,
Duration? pollInterval,
}) {
return PollingIssueEventSourceConfig(
type: type ?? this.type,
pollInterval: pollInterval ?? this.pollInterval,
);
}
}
class ChannelIssueEventSourceConfig extends IssueEventSourceConfig {
const ChannelIssueEventSourceConfig({
required super.type,
required this.reconciliation,
required this.reconcileInterval,
});
final IssueAssistantReconciliationKind reconciliation;
final Duration reconcileInterval;
ChannelIssueEventSourceConfig copyWith({
IssueAssistantEventSourceKind? type,
IssueAssistantReconciliationKind? reconciliation,
Duration? reconcileInterval,
}) {
return ChannelIssueEventSourceConfig(
type: type ?? this.type,
reconciliation: reconciliation ?? this.reconciliation,
reconcileInterval: reconcileInterval ?? this.reconcileInterval,
);
}
}
class CliResponderConfig { class CliResponderConfig {
CliResponderConfig({ CliResponderConfig({
required this.id, required this.id,
@ -407,13 +462,10 @@ class _IssueAssistantConfigResolver {
final notifications = overlay?.notifications; final notifications = overlay?.notifications;
return IssueAssistantConfig( return IssueAssistantConfig(
enabled: overlay?.enabled ?? base?.enabled ?? true, enabled: overlay?.enabled ?? base?.enabled ?? true,
eventSource: eventSource: _resolveEventSource(
overlay?.eventSource ?? base: base?.eventSource,
base?.eventSource ?? overlay: overlay?.eventSource,
_defaultEventSourceForProvider(base), ),
pollInterval: overlay?.pollInterval != null
? _parseDuration(overlay!.pollInterval!)
: base?.pollInterval ?? const Duration(seconds: 30),
maxConcurrentIssues: _resolvePositiveInt( maxConcurrentIssues: _resolvePositiveInt(
value: overlay?.maxConcurrentIssues, value: overlay?.maxConcurrentIssues,
fallback: base?.maxConcurrentIssues ?? 3, fallback: base?.maxConcurrentIssues ?? 3,
@ -452,10 +504,54 @@ class _IssueAssistantConfigResolver {
); );
} }
IssueAssistantEventSourceKind _defaultEventSourceForProvider( IssueEventSourceConfig _resolveEventSource({
IssueAssistantConfig? base, required IssueEventSourceConfig? base,
) { required IssueAssistantEventSourceDocument? overlay,
return base?.eventSource ?? IssueAssistantEventSourceKind.ghPolling; }) {
final type =
overlay?.type ?? base?.type ?? IssueAssistantEventSourceKind.ghPolling;
if (_isPollingEventSource(type)) {
if (overlay?.reconciliation != null ||
overlay?.reconcileInterval != null) {
throw FormatException(
'Polling event sources do not support reconciliation settings.',
);
}
final fallbackPollInterval = switch (base) {
PollingIssueEventSourceConfig(:final pollInterval) => pollInterval,
ChannelIssueEventSourceConfig(:final reconcileInterval) =>
reconcileInterval,
null => const Duration(seconds: 30),
};
return PollingIssueEventSourceConfig(
type: type,
pollInterval: overlay?.pollInterval != null
? _parseDuration(overlay!.pollInterval!)
: fallbackPollInterval,
);
}
final fallbackReconcileInterval = switch (base) {
ChannelIssueEventSourceConfig(:final reconcileInterval) =>
reconcileInterval,
PollingIssueEventSourceConfig(:final pollInterval) => pollInterval,
null => const Duration(seconds: 30),
};
return ChannelIssueEventSourceConfig(
type: type,
reconciliation:
overlay?.reconciliation ??
switch (base) {
ChannelIssueEventSourceConfig(:final reconciliation) =>
reconciliation,
_ => IssueAssistantReconciliationKind.continuous,
},
reconcileInterval: overlay?.reconcileInterval != null
? _parseDuration(overlay!.reconcileInterval!)
: overlay?.pollInterval != null
? _parseDuration(overlay!.pollInterval!)
: fallbackReconcileInterval,
);
} }
} }
@ -582,6 +678,13 @@ void _assertCamelCaseConfigKeys(Map<String, dynamic> root) {
final issueAssistant = defaults['issueAssistant']; final issueAssistant = defaults['issueAssistant'];
if (issueAssistant is Map<String, dynamic>) { if (issueAssistant is Map<String, dynamic>) {
_assertCamelCaseMap(issueAssistant, scope: 'defaults.issueAssistant'); _assertCamelCaseMap(issueAssistant, scope: 'defaults.issueAssistant');
final eventSource = issueAssistant['eventSource'];
if (eventSource is Map<String, dynamic>) {
_assertCamelCaseMap(
eventSource,
scope: 'defaults.issueAssistant.eventSource',
);
}
final responders = issueAssistant['responders']; final responders = issueAssistant['responders'];
if (responders is List) { if (responders is List) {
for (final responder in responders) { for (final responder in responders) {
@ -621,6 +724,13 @@ void _assertCamelCaseConfigKeys(Map<String, dynamic> root) {
issueAssistant, issueAssistant,
scope: 'projects.${entry.key}.issueAssistant', scope: 'projects.${entry.key}.issueAssistant',
); );
final eventSource = issueAssistant['eventSource'];
if (eventSource is Map<String, dynamic>) {
_assertCamelCaseMap(
eventSource,
scope: 'projects.${entry.key}.issueAssistant.eventSource',
);
}
final responders = issueAssistant['responders']; final responders = issueAssistant['responders'];
if (responders is List) { if (responders is List) {
for (final responder in responders) { for (final responder in responders) {
@ -648,6 +758,80 @@ void _assertCamelCaseConfigKeys(Map<String, dynamic> root) {
} }
} }
void _normalizeLegacyEventSourceConfig(Map<String, dynamic> root) {
void normalizeIssueAssistant(
Map<String, dynamic> issueAssistant, {
required IssueAssistantEventSourceKind? defaultType,
}) {
final rawEventSource = issueAssistant['eventSource'];
Map<String, dynamic>? normalizedEventSource;
if (rawEventSource is String) {
normalizedEventSource = <String, dynamic>{'type': rawEventSource};
} else if (rawEventSource is Map<String, dynamic>) {
normalizedEventSource = Map<String, dynamic>.from(rawEventSource);
} else if (rawEventSource == null) {
if (issueAssistant.containsKey('pollInterval') ||
issueAssistant.containsKey('reconciliation') ||
issueAssistant.containsKey('reconcileInterval')) {
normalizedEventSource = <String, dynamic>{};
}
}
if (normalizedEventSource == null) {
return;
}
if (defaultType != null && !normalizedEventSource.containsKey('type')) {
normalizedEventSource['type'] = _eventSourceConfigValue(defaultType);
}
for (final field in [
'pollInterval',
'reconciliation',
'reconcileInterval',
]) {
if (issueAssistant.containsKey(field) &&
!normalizedEventSource.containsKey(field)) {
normalizedEventSource[field] = issueAssistant.remove(field);
}
}
issueAssistant['eventSource'] = normalizedEventSource;
}
final defaults = root['defaults'];
if (defaults is Map<String, dynamic>) {
final issueAssistant = defaults['issueAssistant'];
if (issueAssistant is Map<String, dynamic>) {
normalizeIssueAssistant(
issueAssistant,
defaultType: IssueAssistantEventSourceKind.ghPolling,
);
}
}
final projects = root['projects'];
if (projects is Map<String, dynamic>) {
for (final entry in projects.entries) {
final project = entry.value;
if (project is! Map<String, dynamic>) {
continue;
}
final issueAssistant = project['issueAssistant'];
if (issueAssistant is! Map<String, dynamic>) {
continue;
}
normalizeIssueAssistant(
issueAssistant,
defaultType: project['provider'] == 'gitea'
? IssueAssistantEventSourceKind.teaPolling
: IssueAssistantEventSourceKind.ghPolling,
);
}
}
}
void _assertCamelCaseMap(Map<String, dynamic> map, {required String scope}) { void _assertCamelCaseMap(Map<String, dynamic> map, {required String scope}) {
for (final key in map.keys) { for (final key in map.keys) {
if (key.contains('_')) { if (key.contains('_')) {
@ -698,7 +882,6 @@ ${_YamlEmitter().write({'defaults': defaultsSection})}
# capabilities: ["planning", "bug_verification"] # capabilities: ["planning", "bug_verification"]
# commentTag: code-work-spawner # commentTag: code-work-spawner
# eventSource: gh/polling
# commentPrefixTemplate: | # commentPrefixTemplate: |
${_commentBlock(defaultCommentPrefixTemplate, indent: ' # ')} ${_commentBlock(defaultCommentPrefixTemplate, indent: ' # ')}
# commentFooterTemplate: | # commentFooterTemplate: |
@ -727,7 +910,10 @@ ${_YamlEmitter().write({'projects': projectsSection})}
# issueAssistant: # issueAssistant:
# enabled: ${assistant?.enabled ?? true} # enabled: ${assistant?.enabled ?? true}
# eventSource: gh/gosmee # eventSource:
# type: gh/gosmee
# reconciliation: startup-only
# reconcileInterval: 30s
# mentionTriggers: ["@helper"] # mentionTriggers: ["@helper"]
# sessionPrefix: sample # sessionPrefix: sample
''' '''

View File

@ -87,7 +87,6 @@ class IssueAssistantConfigDocument {
const IssueAssistantConfigDocument({ const IssueAssistantConfigDocument({
this.enabled, this.enabled,
this.eventSource, this.eventSource,
this.pollInterval,
this.maxConcurrentIssues, this.maxConcurrentIssues,
this.mentionTriggers, this.mentionTriggers,
this.prompt, this.prompt,
@ -100,8 +99,7 @@ class IssueAssistantConfigDocument {
}); });
final bool? enabled; final bool? enabled;
final IssueAssistantEventSourceKind? eventSource; final IssueAssistantEventSourceDocument? eventSource;
final String? pollInterval;
final int? maxConcurrentIssues; final int? maxConcurrentIssues;
final List<String>? mentionTriggers; final List<String>? mentionTriggers;
final String? prompt; final String? prompt;
@ -118,6 +116,34 @@ class IssueAssistantConfigDocument {
Map<String, dynamic> toJson() => _$IssueAssistantConfigDocumentToJson(this); Map<String, dynamic> toJson() => _$IssueAssistantConfigDocumentToJson(this);
} }
@JsonSerializable(
checked: true,
createToJson: true,
disallowUnrecognizedKeys: true,
explicitToJson: true,
includeIfNull: false,
)
class IssueAssistantEventSourceDocument {
const IssueAssistantEventSourceDocument({
this.type,
this.pollInterval,
this.reconciliation,
this.reconcileInterval,
});
final IssueAssistantEventSourceKind? type;
final String? pollInterval;
final IssueAssistantReconciliationKind? reconciliation;
final String? reconcileInterval;
factory IssueAssistantEventSourceDocument.fromJson(
Map<String, dynamic> json,
) => _$IssueAssistantEventSourceDocumentFromJson(json);
Map<String, dynamic> toJson() =>
_$IssueAssistantEventSourceDocumentToJson(this);
}
@JsonSerializable( @JsonSerializable(
checked: true, checked: true,
createToJson: true, createToJson: true,
@ -194,6 +220,13 @@ enum IssueAssistantEventSourceKind {
teaGosmee, teaGosmee,
} }
enum IssueAssistantReconciliationKind {
@JsonValue('startup-only')
startupOnly,
@JsonValue('continuous')
continuous,
}
@JsonEnum(fieldRename: FieldRename.snake) @JsonEnum(fieldRename: FieldRename.snake)
enum IssueTrackerProvider { github, gitea } enum IssueTrackerProvider { github, gitea }

View File

@ -48,6 +48,16 @@ class RoutedIssueEventSource implements IssueEventSource {
required List<ProjectConfig> projects, required List<ProjectConfig> projects,
required IssueEventBatchHandler onBatch, required IssueEventBatchHandler onBatch,
}) async { }) async {
final startupReconcileProjects = projects
.where(_shouldRunStartupReconciliation)
.toList(growable: false);
if (startupReconcileProjects.isNotEmpty) {
await sources[IssueAssistantEventSourceKind.ghPolling]!.runOnce(
projects: startupReconcileProjects,
onBatch: onBatch,
);
}
final projectsBySource = final projectsBySource =
<IssueAssistantEventSourceKind, List<ProjectConfig>>{ <IssueAssistantEventSourceKind, List<ProjectConfig>>{
for (final eventSource in sources.keys) for (final eventSource in sources.keys)
@ -98,31 +108,38 @@ class RoutedIssueEventSource implements IssueEventSource {
Iterable<IssueAssistantEventSourceKind> _registeredSourcesFor( Iterable<IssueAssistantEventSourceKind> _registeredSourcesFor(
ProjectConfig project, ProjectConfig project,
) sync* { ) sync* {
switch (project.issueAssistant.eventSource) { switch (project.issueAssistant.eventSource.type) {
case IssueAssistantEventSourceKind.ghPolling: case IssueAssistantEventSourceKind.ghPolling:
yield IssueAssistantEventSourceKind.ghPolling; yield IssueAssistantEventSourceKind.ghPolling;
case IssueAssistantEventSourceKind.teaPolling: case IssueAssistantEventSourceKind.teaPolling:
yield IssueAssistantEventSourceKind.teaPolling; yield IssueAssistantEventSourceKind.teaPolling;
case IssueAssistantEventSourceKind.ghGosmee: case IssueAssistantEventSourceKind.ghGosmee:
// Keep reconciliation polling active for startup sync and missed if (_shouldRunContinuousReconciliation(project)) {
// webhook recovery while gosmee handles low-latency issue/comment yield IssueAssistantEventSourceKind.ghPolling;
// events for GitHub projects. }
yield IssueAssistantEventSourceKind.ghPolling;
yield IssueAssistantEventSourceKind.ghGosmee; yield IssueAssistantEventSourceKind.ghGosmee;
case IssueAssistantEventSourceKind.ghWebhookForward: case IssueAssistantEventSourceKind.ghWebhookForward:
// Keep reconciliation polling active for startup sync and missed if (_shouldRunContinuousReconciliation(project)) {
// webhook recovery while the forwarded channel handles low-latency yield IssueAssistantEventSourceKind.ghPolling;
// issue/comment events. }
yield IssueAssistantEventSourceKind.ghPolling;
yield IssueAssistantEventSourceKind.ghWebhookForward; yield IssueAssistantEventSourceKind.ghWebhookForward;
case IssueAssistantEventSourceKind.teaGosmee: case IssueAssistantEventSourceKind.teaGosmee:
// Keep reconciliation polling active for startup sync and missed if (_shouldRunContinuousReconciliation(project)) {
// webhook recovery while gosmee handles low-latency issue/comment yield IssueAssistantEventSourceKind.teaPolling;
// events for Gitea projects. }
yield IssueAssistantEventSourceKind.teaPolling;
yield IssueAssistantEventSourceKind.teaGosmee; yield IssueAssistantEventSourceKind.teaGosmee;
} }
} }
bool _shouldRunStartupReconciliation(ProjectConfig project) =>
project.issueAssistant.eventSource.usesChannelEventSource;
bool _shouldRunContinuousReconciliation(ProjectConfig project) =>
project.issueAssistant.eventSource.usesChannelEventSource &&
project.issueAssistant.eventSource is ChannelIssueEventSourceConfig &&
(project.issueAssistant.eventSource as ChannelIssueEventSourceConfig)
.reconciliation ==
IssueAssistantReconciliationKind.continuous;
} }
class PollingIssueEventSource implements IssueEventSource { class PollingIssueEventSource implements IssueEventSource {
@ -242,7 +259,12 @@ class PollingIssueEventSource implements IssueEventSource {
for (final state in dueStates) { for (final state in dueStates) {
while (!state.nextDueAt.isAfter(batchCompletedAt)) { while (!state.nextDueAt.isAfter(batchCompletedAt)) {
state.nextDueAt = state.nextDueAt.add( state.nextDueAt = state.nextDueAt.add(
state.project.issueAssistant.pollInterval, switch (state.project.issueAssistant.eventSource) {
PollingIssueEventSourceConfig(:final pollInterval) =>
pollInterval,
ChannelIssueEventSourceConfig(:final reconcileInterval) =>
reconcileInterval,
},
); );
} }
} }

View File

@ -103,9 +103,15 @@ projects:
defaultCommentFooterTemplate, defaultCommentFooterTemplate,
); );
expect( expect(
config.projects['sample']!.issueAssistant.pollInterval, (config.projects['sample']!.issueAssistant.eventSource
as PollingIssueEventSourceConfig)
.pollInterval,
const Duration(seconds: 30), const Duration(seconds: 30),
); );
expect(
config.projects['sample']!.issueAssistant.eventSource,
isA<PollingIssueEventSourceConfig>(),
);
expect(config.projects['sample']!.issueAssistant.maxConcurrentIssues, 3); expect(config.projects['sample']!.issueAssistant.maxConcurrentIssues, 3);
}); });
@ -351,7 +357,7 @@ projects:
// And the repository slug still parses normally. // And the repository slug still parses normally.
expect(config.projects['sample']!.repoSlug.fullName, 'owner/sample'); expect(config.projects['sample']!.repoSlug.fullName, 'owner/sample');
expect( expect(
config.projects['sample']!.issueAssistant.eventSource, config.projects['sample']!.issueAssistant.eventSource.type,
IssueAssistantEventSourceKind.teaPolling, IssueAssistantEventSourceKind.teaPolling,
); );
}); });
@ -374,7 +380,8 @@ projects:
repo: owner/sample repo: owner/sample
path: ${tempDir.path} path: ${tempDir.path}
issueAssistant: issueAssistant:
eventSource: gh/gosmee eventSource:
type: gh/gosmee
'''); ''');
// When loading the app config from disk. // When loading the app config from disk.
@ -382,7 +389,7 @@ projects:
// Then the project keeps gh/gosmee as its event source. // Then the project keeps gh/gosmee as its event source.
expect( expect(
config.projects['sample']!.issueAssistant.eventSource, config.projects['sample']!.issueAssistant.eventSource.type,
IssueAssistantEventSourceKind.ghGosmee, IssueAssistantEventSourceKind.ghGosmee,
); );
@ -409,7 +416,8 @@ projects:
repo: owner/sample repo: owner/sample
path: ${tempDir.path} path: ${tempDir.path}
issueAssistant: issueAssistant:
eventSource: gh/gosmee eventSource:
type: gh/gosmee
'''); ''');
// When loading the app config from disk. // When loading the app config from disk.
@ -448,7 +456,8 @@ projects:
repo: owner/sample repo: owner/sample
path: ${tempDir.path} path: ${tempDir.path}
issueAssistant: issueAssistant:
eventSource: gh/webhook-forward eventSource:
type: gh/webhook-forward
'''); ''');
// When loading the app config from disk. // When loading the app config from disk.
@ -456,7 +465,7 @@ projects:
// Then the project keeps gh/webhook-forward as its event source. // Then the project keeps gh/webhook-forward as its event source.
expect( expect(
config.projects['sample']!.issueAssistant.eventSource, config.projects['sample']!.issueAssistant.eventSource.type,
IssueAssistantEventSourceKind.ghWebhookForward, IssueAssistantEventSourceKind.ghWebhookForward,
); );
@ -464,6 +473,95 @@ projects:
expect(config.projects['sample']!.provider, IssueTrackerProvider.github); expect(config.projects['sample']!.provider, IssueTrackerProvider.github);
}); });
/// ```gherkin
/// Scenario: Load channel reconciliation settings
/// Given a GitHub project config that uses gh/webhook-forward reconciliation settings
/// When loading the app config from disk
/// Then the project keeps the configured reconciliation mode
/// And the configured reconcile interval is parsed for channel recovery
/// ```
test('Load channel reconciliation settings', () async {
// Given a GitHub project config that uses gh/webhook-forward reconciliation settings.
final tempDir = await Directory.systemTemp.createTemp(
'cws-channel-reconcile-',
);
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
projects:
sample:
provider: github
repo: owner/sample
path: ${tempDir.path}
issueAssistant:
eventSource:
type: gh/webhook-forward
reconciliation: startup-only
reconcileInterval: 5m
''');
// When loading the app config from disk.
final config = await AppConfig.load(configFile.path);
// Then the project keeps the configured reconciliation mode.
expect(
(config.projects['sample']!.issueAssistant.eventSource
as ChannelIssueEventSourceConfig)
.reconciliation,
IssueAssistantReconciliationKind.startupOnly,
);
// And the configured reconcile interval is parsed for channel recovery.
expect(
(config.projects['sample']!.issueAssistant.eventSource
as ChannelIssueEventSourceConfig)
.reconcileInterval,
const Duration(minutes: 5),
);
});
/// ```gherkin
/// Scenario: Reuse pollInterval as the fallback reconcile interval
/// Given a legacy channel-based project config that still sets pollInterval
/// When loading the app config from disk
/// Then the project keeps the parsed poll interval for backward compatibility
/// And the same value is reused as the reconcile interval
/// ```
test('Reuse pollInterval as the fallback reconcile interval', () async {
// Given a legacy channel-based project config that still sets pollInterval.
final tempDir = await Directory.systemTemp.createTemp(
'cws-legacy-channel-poll-',
);
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
projects:
sample:
provider: github
repo: owner/sample
path: ${tempDir.path}
issueAssistant:
eventSource:
type: gh/webhook-forward
pollInterval: 45s
''');
// When loading the app config from disk.
final config = await AppConfig.load(configFile.path);
// Then the project keeps the parsed poll interval for backward compatibility.
expect(
config.projects['sample']!.issueAssistant.eventSource,
isA<ChannelIssueEventSourceConfig>(),
);
// And the same value is reused as the reconcile interval.
expect(
(config.projects['sample']!.issueAssistant.eventSource
as ChannelIssueEventSourceConfig)
.reconcileInterval,
const Duration(seconds: 45),
);
});
/// ```gherkin /// ```gherkin
/// Scenario: Reject gh/webhook-forward for non-GitHub providers /// Scenario: Reject gh/webhook-forward for non-GitHub providers
/// Given a Gitea project config that opts into gh/webhook-forward /// Given a Gitea project config that opts into gh/webhook-forward
@ -483,7 +581,8 @@ projects:
repo: owner/sample repo: owner/sample
path: ${tempDir.path} path: ${tempDir.path}
issueAssistant: issueAssistant:
eventSource: gh/webhook-forward eventSource:
type: gh/webhook-forward
'''); ''');
// When loading the app config from disk. // When loading the app config from disk.
@ -520,7 +619,8 @@ projects:
repo: owner/sample repo: owner/sample
path: ${tempDir.path} path: ${tempDir.path}
issueAssistant: issueAssistant:
eventSource: tea/gosmee eventSource:
type: tea/gosmee
'''); ''');
// When loading the app config from disk. // When loading the app config from disk.
@ -528,7 +628,7 @@ projects:
// Then the project keeps tea/gosmee as its event source. // Then the project keeps tea/gosmee as its event source.
expect( expect(
config.projects['sample']!.issueAssistant.eventSource, config.projects['sample']!.issueAssistant.eventSource.type,
IssueAssistantEventSourceKind.teaGosmee, IssueAssistantEventSourceKind.teaGosmee,
); );
@ -555,7 +655,8 @@ projects:
repo: owner/sample repo: owner/sample
path: ${tempDir.path} path: ${tempDir.path}
issueAssistant: issueAssistant:
eventSource: tea/gosmee eventSource:
type: tea/gosmee
'''); ''');
// When loading the app config from disk. // When loading the app config from disk.
@ -719,6 +820,16 @@ projects:
.replaceFirst(' # timeout: 10m', ' timeout: 10m') .replaceFirst(' # timeout: 10m', ' timeout: 10m')
.replaceFirst(' # issueAssistant:', ' issueAssistant:') .replaceFirst(' # issueAssistant:', ' issueAssistant:')
.replaceFirst(' # enabled: true', ' enabled: true') .replaceFirst(' # enabled: true', ' enabled: true')
.replaceFirst(' # eventSource:', ' eventSource:')
.replaceFirst(' # type: gh/gosmee', ' type: gh/gosmee')
.replaceFirst(
' # reconciliation: startup-only',
' reconciliation: startup-only',
)
.replaceFirst(
' # reconcileInterval: 30s',
' reconcileInterval: 30s',
)
.replaceFirst( .replaceFirst(
' # mentionTriggers: ["@helper"]', ' # mentionTriggers: ["@helper"]',
' mentionTriggers: ["@helper"]', ' mentionTriggers: ["@helper"]',
@ -749,6 +860,12 @@ projects:
expect(config.projects['sample']!.issueAssistant.mentionTriggers, [ expect(config.projects['sample']!.issueAssistant.mentionTriggers, [
'@helper', '@helper',
]); ]);
expect(
(config.projects['sample']!.issueAssistant.eventSource
as ChannelIssueEventSourceConfig)
.reconciliation,
IssueAssistantReconciliationKind.startupOnly,
);
}); });
}); });
} }

View File

@ -208,7 +208,7 @@ projects:
/// And a gosmee script that starts forwarding without delivering an event /// And a gosmee script that starts forwarding without delivering an event
/// And a tea script that returns a new issue only on a later reconciliation poll /// And a tea script that returns a new issue only on a later reconciliation poll
/// And a responder that emits a planning reply /// And a responder that emits a planning reply
/// And an orchestrator-style config that uses tea/gosmee with a short poll interval /// And an orchestrator-style config that uses tea/gosmee with continuous reconciliation
/// When the long-running app starts and enough time passes for reconciliation polling /// When the long-running app starts and enough time passes for reconciliation polling
/// Then it posts exactly one reply for the issue discovered by polling /// Then it posts exactly one reply for the issue discovered by polling
/// And the app performs more than one Gitea issue listing request while running /// And the app performs more than one Gitea issue listing request while running
@ -344,13 +344,12 @@ exit 1
'summary': 'posted reconciliation advice', 'summary': 'posted reconciliation advice',
}); });
// And an orchestrator-style config that uses tea/gosmee with a short poll interval. // And an orchestrator-style config that uses tea/gosmee with continuous reconciliation.
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('''
defaults: defaults:
issueAssistant: issueAssistant:
enabled: true enabled: true
pollInterval: 1s
mentionTriggers: ["@helper"] mentionTriggers: ["@helper"]
responders: responders:
- id: primary - id: primary
@ -363,6 +362,8 @@ projects:
path: ${repoDir.path} path: ${repoDir.path}
issueAssistant: issueAssistant:
eventSource: tea/gosmee eventSource: tea/gosmee
reconciliation: continuous
reconcileInterval: 1s
'''); ''');
final app = await IssueAssistantApp.open( final app = await IssueAssistantApp.open(
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),

View File

@ -169,6 +169,13 @@ JSON
exit 0 exit 0
fi fi
if [[ "\$1" == "api" && "\$4" == "search/issues" ]]; then
cat <<'JSON'
{"total_count":0,"incomplete_results":false,"items":[]}
JSON
exit 0
fi
echo "unexpected gh args: \$*" >&2 echo "unexpected gh args: \$*" >&2
exit 1 exit 1
'''); ''');
@ -232,7 +239,7 @@ projects:
/// And a gh script that starts webhook forwarding without delivering an event /// And a gh script that starts webhook forwarding without delivering an event
/// And the same gh script returns a new issue only on a later reconciliation poll /// And the same gh script returns a new issue only on a later reconciliation poll
/// And a responder that emits a planning reply /// And a responder that emits a planning reply
/// And an orchestrator-style config that uses gh/webhook-forward with a short poll interval /// And an orchestrator-style config that uses gh/webhook-forward with continuous reconciliation
/// When the long-running app starts and enough time passes for reconciliation polling /// When the long-running app starts and enough time passes for reconciliation polling
/// Then it posts exactly one reply for the issue discovered by polling /// Then it posts exactly one reply for the issue discovered by polling
/// And the gh webhook forward command is still invoked for the GitHub project /// And the gh webhook forward command is still invoked for the GitHub project
@ -348,13 +355,12 @@ exit 1
'summary': 'posted reconciliation advice', 'summary': 'posted reconciliation advice',
}); });
// And an orchestrator-style config that uses gh/webhook-forward with a short poll interval. // And an orchestrator-style config that uses gh/webhook-forward with continuous reconciliation.
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('''
defaults: defaults:
issueAssistant: issueAssistant:
enabled: true enabled: true
pollInterval: 1s
mentionTriggers: ["@helper"] mentionTriggers: ["@helper"]
responders: responders:
- id: primary - id: primary
@ -367,6 +373,8 @@ projects:
path: ${repoDir.path} path: ${repoDir.path}
issueAssistant: issueAssistant:
eventSource: gh/webhook-forward eventSource: gh/webhook-forward
reconciliation: continuous
reconcileInterval: 1s
'''); ''');
final app = await IssueAssistantApp.open( final app = await IssueAssistantApp.open(
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),

View File

@ -0,0 +1,147 @@
import 'package:code_work_spawner/code_work_spawner.dart';
import 'package:github/github.dart';
import 'package:test/test.dart';
void main() {
/// ```gherkin
/// Feature: Routed issue event sources
///
/// As a maintainer configuring channel-based event sources
/// I want startup reconciliation and continuous reconciliation to route differently
/// So that channel sources can opt into either startup-only or background recovery polling
/// ```
group('RoutedIssueEventSource', () {
/// ```gherkin
/// Scenario: Run startup-only reconciliation for channel sources
/// Given a routed event source with fake polling and channel handlers
/// And a project that uses gh/webhook-forward with startup-only reconciliation
/// When the routed event source starts in long-running mode
/// Then the polling source runs once for startup reconciliation
/// And only the channel source stays registered for long-running execution
/// ```
test('Run startup-only reconciliation for channel sources', () async {
// Given a routed event source with fake polling and channel handlers.
final polling = _RecordingIssueEventSource();
final webhookForward = _RecordingIssueEventSource();
final routed = RoutedIssueEventSource(
sources: {
IssueAssistantEventSourceKind.ghPolling: polling,
IssueAssistantEventSourceKind.teaPolling: polling,
IssueAssistantEventSourceKind.ghWebhookForward: webhookForward,
},
);
// And a project that uses gh/webhook-forward with startup-only reconciliation.
final project = _project(
eventSource: IssueAssistantEventSourceKind.ghWebhookForward,
reconciliation: IssueAssistantReconciliationKind.startupOnly,
);
// When the routed event source starts in long-running mode.
await routed.run(projects: [project], onBatch: (_) async {});
// Then the polling source runs once for startup reconciliation.
expect(polling.runOnceProjects, ['sample']);
// And only the channel source stays registered for long-running execution.
expect(polling.runProjects, isEmpty);
expect(webhookForward.runProjects, ['sample']);
});
/// ```gherkin
/// Scenario: Keep continuous reconciliation polling active for channel sources
/// Given a routed event source with fake polling and channel handlers
/// And a project that uses gh/webhook-forward with continuous reconciliation
/// When the routed event source starts in long-running mode
/// Then the polling source runs once for startup reconciliation
/// And the polling source also stays registered for background recovery
/// ```
test(
'Keep continuous reconciliation polling active for channel sources',
() async {
// Given a routed event source with fake polling and channel handlers.
final polling = _RecordingIssueEventSource();
final webhookForward = _RecordingIssueEventSource();
final routed = RoutedIssueEventSource(
sources: {
IssueAssistantEventSourceKind.ghPolling: polling,
IssueAssistantEventSourceKind.teaPolling: polling,
IssueAssistantEventSourceKind.ghWebhookForward: webhookForward,
},
);
// And a project that uses gh/webhook-forward with continuous reconciliation.
final project = _project(
eventSource: IssueAssistantEventSourceKind.ghWebhookForward,
reconciliation: IssueAssistantReconciliationKind.continuous,
);
// When the routed event source starts in long-running mode.
await routed.run(projects: [project], onBatch: (_) async {});
// Then the polling source runs once for startup reconciliation.
expect(polling.runOnceProjects, ['sample']);
// And the polling source also stays registered for background recovery.
expect(polling.runProjects, ['sample']);
expect(webhookForward.runProjects, ['sample']);
},
);
});
}
class _RecordingIssueEventSource implements IssueEventSource {
final List<String> runProjects = <String>[];
final List<String> runOnceProjects = <String>[];
@override
Future<void> run({
required List<ProjectConfig> projects,
required IssueEventBatchHandler onBatch,
}) async {
runProjects.addAll(projects.map((project) => project.key));
}
@override
Future<void> runOnce({
required List<ProjectConfig> projects,
required IssueEventBatchHandler onBatch,
}) async {
runOnceProjects.addAll(projects.map((project) => project.key));
}
@override
Future<void> close() async {}
}
ProjectConfig _project({
required IssueAssistantEventSourceKind eventSource,
required IssueAssistantReconciliationKind reconciliation,
}) {
return ProjectConfig(
key: 'sample',
provider: IssueTrackerProvider.github,
repo: 'owner/sample',
repoSlug: RepositorySlug('owner', 'sample'),
path: '/tmp/sample',
defaultBranch: 'main',
sessionPrefix: 'sam',
issueAssistant: IssueAssistantConfig(
enabled: true,
eventSource: ChannelIssueEventSourceConfig(
type: eventSource,
reconciliation: reconciliation,
reconcileInterval: const Duration(seconds: 30),
),
maxConcurrentIssues: 3,
mentionTriggers: const ['@helper'],
prompt: defaultIssueAssistantPrompt,
responders: const <CliResponderConfig>[],
capabilities: {AssistantCapability.planning},
notifications: const <NotificationConfig>[],
commentTag: 'code-work-spawner',
commentPrefixTemplate: defaultCommentPrefixTemplate,
commentFooterTemplate: defaultCommentFooterTemplate,
),
);
}

View File

@ -576,10 +576,24 @@ Future<void> writeGitHubGosmeeGhScript({
required int webhookId, required int webhookId,
required int issueNumber, required int issueNumber,
required List<Map<String, Object?>> comments, required List<Map<String, Object?>> comments,
List<Map<String, Object?>> issueListResponse = const [],
File? ghLog, File? ghLog,
File? postedBodyFile, File? postedBodyFile,
String? viewerLogin, String? viewerLogin,
}) async { }) async {
final searchItems = issueListResponse
.map(
(issue) => <String, Object?>{
...issue,
'repository_url': 'https://api.github.com/repos/$repo',
},
)
.toList(growable: false);
final searchResponse = jsonEncode({
'total_count': searchItems.length,
'incomplete_results': false,
'items': searchItems,
});
final buffer = StringBuffer() final buffer = StringBuffer()
..writeln('#!/usr/bin/env bash') ..writeln('#!/usr/bin/env bash')
..writeln('set -euo pipefail'); ..writeln('set -euo pipefail');
@ -599,6 +613,14 @@ Future<void> writeGitHubGosmeeGhScript({
..writeln(' exit 0') ..writeln(' exit 0')
..writeln('fi'); ..writeln('fi');
buffer
..writeln(r'''if [[ "$1" == "api" && "$4" == "search/issues" ]]; then''')
..writeln(" cat <<'JSON'")
..writeln(searchResponse)
..writeln('JSON')
..writeln(' exit 0')
..writeln('fi');
buffer buffer
..writeln( ..writeln(
'if [[ "\$1" == "api" && "\$4" == ' 'if [[ "\$1" == "api" && "\$4" == '