feat: Add minimal GitHub repo configuration and issue assistant setup

fix: Export workspace manager in code work spawner
feat: Enhance CLI responder to handle JSONL events
feat: Implement job status enumeration in database
feat: Add logging capabilities to IssueAssistantApp
This commit is contained in:
insleker 2026-04-04 22:12:03 +08:00
parent aa8d122ab9
commit 42b89719fd
16 changed files with 830 additions and 86 deletions

View File

@ -14,6 +14,9 @@ jobs:
- name: Install dependencies
run: dart pub get
- name: Build generated files
run: dart run build_runner build --delete-conflicting-outputs
- name: Static analysis
run: dart analyze

3
.gitignore vendored
View File

@ -8,6 +8,7 @@ agent-orchestrator.yaml
# !/.agents
!/.git
!/.github
!/.vscode
/skills/*
!/skills/dart-gherkin-tests
*.g.dart

5
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"recommendations": [
"qwtel.sqlite-viewer"
]
}

View File

@ -49,7 +49,7 @@ defaults:
responders:
- id: codex
command: codex
args: ["exec", "--json-output"]
args: ["exec", "--json"]
timeout: 10m
- id: opencode
command: opencode
@ -66,8 +66,17 @@ projects:
Project entries can override `issueAssistant` fields when a repo needs a
different prompt, mention triggers, or responder chain.
For a more minimal Agent Orchestrator compatible subset, start from
[`examples/simple-github.yaml`](examples/simple-github.yaml). This repo accepts
AO-style `dataDir`, `worktreeDir`, and core `projects` fields, and uses
`worktreeDir` for bug-verification worktrees.
## Run
```bash
dart run build_runner build
```
Single poll cycle:
```bash

View File

@ -2,8 +2,11 @@ import 'dart:io';
import 'package:args/args.dart';
import 'package:code_work_spawner/code_work_spawner.dart';
import 'package:logging/logging.dart';
Future<void> main(List<String> arguments) async {
_configureLogging();
final parser = ArgParser()
..addFlag(
'once',
@ -36,6 +39,22 @@ Future<void> main(List<String> arguments) async {
}
final config = await AppConfig.load(results['config'] as String);
final logger = Logger('code_work_spawner.cli');
logger.info(
'loaded config=${config.configPath} '
'projects=${config.projects.length} '
'worktreeDir=${config.worktreeDir ?? "(system temp)"}',
);
for (final project in config.projects.values) {
final repoDirectory = Directory(project.path);
logger.info(
'project=${project.key} '
'repo=${project.repo} '
'path=${project.path} '
'exists=${await repoDirectory.exists()} '
'enabled=${project.issueAssistant.enabled}',
);
}
final app = await IssueAssistantApp.open(
config: config,
databasePath: results['db'] as String,
@ -53,3 +72,13 @@ Future<void> main(List<String> arguments) async {
await app.close();
}
}
void _configureLogging() {
hierarchicalLoggingEnabled = true;
Logger.root.level = Level.INFO;
Logger.root.onRecord.listen((record) {
stdout.writeln(
'[${record.loggerName}] ${record.level.name}: ${record.message}',
);
});
}

View File

@ -0,0 +1,24 @@
# Minimal setup for a single GitHub repo with GitHub Issues
# AO-compatible subset supported by code_work_spawner.
# Replies require at least one configured responder.
dataDir: ~/.agent-orchestrator
worktreeDir: ~/.worktrees
defaults:
issueAssistant:
enabled: true
pollInterval: 5m
mentionTriggers: ["@codex"]
responders:
# Replace this with a responder available on your machine.
- id: codex
command: codex
args: ["exec", "--json"]
timeout: 10m
projects:
code-work-spawner:
repo: existedinnettw/code_work_spawner
path: ~/code_work_spawner
defaultBranch: main

View File

@ -4,5 +4,5 @@ pre-commit:
run: dart format --output=none --set-exit-if-changed .
analyze:
run: dart analyze
test:
run: dart test
# test:
# run: dart test

View File

@ -2,3 +2,4 @@ export 'src/config.dart';
export 'src/database.dart';
export 'src/github_client.dart';
export 'src/issue_assistant_app.dart';
export 'src/workspace_manager.dart';

View File

@ -87,6 +87,11 @@ class CliResponderRunner {
try {
return (jsonDecode(trimmed) as Map).cast<String, dynamic>();
} on FormatException {
final eventJson = _decodeJsonlEvents(trimmed);
if (eventJson != null) {
return eventJson;
}
final first = trimmed.indexOf('{');
final last = trimmed.lastIndexOf('}');
if (first == -1 || last <= first) {
@ -96,6 +101,45 @@ class CliResponderRunner {
return (jsonDecode(snippet) as Map).cast<String, dynamic>();
}
}
Map<String, dynamic>? _decodeJsonlEvents(String stdout) {
Map<String, dynamic>? lastEvent;
for (final line in const LineSplitter().convert(stdout)) {
final trimmed = line.trim();
if (trimmed.isEmpty || !trimmed.startsWith('{')) {
continue;
}
try {
final decoded = jsonDecode(trimmed);
if (decoded is! Map) {
continue;
}
lastEvent = decoded.cast<String, dynamic>();
final item = lastEvent['item'];
if (item is Map &&
item['type']?.toString() == 'agent_message' &&
item['text'] is String) {
final text = (item['text'] as String).trim();
if (text.startsWith('{')) {
return (jsonDecode(text) as Map).cast<String, dynamic>();
}
}
} on FormatException {
continue;
}
}
if (lastEvent case {'text': final String text}) {
final trimmed = text.trim();
if (trimmed.startsWith('{')) {
return (jsonDecode(trimmed) as Map).cast<String, dynamic>();
}
}
return null;
}
}
class ResponderResult {

View File

@ -7,11 +7,15 @@ import 'package:yaml/yaml.dart';
class AppConfig {
AppConfig({
required this.configPath,
required this.dataDir,
required this.worktreeDir,
required this.defaults,
required this.projects,
});
final String configPath;
final String? dataDir;
final String? worktreeDir;
final IssueAssistantConfig defaults;
final Map<String, ProjectConfig> projects;
@ -23,11 +27,80 @@ class AppConfig {
throw const FormatException('Config root must be a YAML map.');
}
final defaultsMap = _asMap(yaml['defaults']);
final root = _asMap(yaml);
final dialect = _detectDialect(root);
return switch (dialect) {
_ConfigDialect.current => _loadCurrentConfig(path, root),
_ConfigDialect.aoSubset => _loadAoSubsetConfig(path, root),
};
}
static AppConfig _loadCurrentConfig(String path, Map<String, dynamic> root) {
_validateKeys(
scope: 'config',
map: root,
allowed: {'defaults', 'projects', 'dataDir', 'worktreeDir'},
);
final defaultsMap = _asMap(root['defaults']);
final defaultAssistantMap = _asMap(defaultsMap['issueAssistant']);
final defaults = IssueAssistantConfig.fromMap(defaultAssistantMap);
final projects = _loadProjects(
root['projects'],
defaultAssistant: defaults,
defaultAssistantMap: defaultAssistantMap,
);
final projectsNode = yaml['projects'];
return AppConfig(
configPath: p.normalize(p.absolute(path)),
dataDir: _expandOptionalPath(root['dataDir']?.toString()),
worktreeDir: _expandOptionalPath(root['worktreeDir']?.toString()),
defaults: defaults,
projects: projects,
);
}
static AppConfig _loadAoSubsetConfig(String path, Map<String, dynamic> root) {
_validateKeys(
scope: 'config',
map: root,
allowed: {'defaults', 'projects', 'dataDir', 'worktreeDir'},
);
final defaultsMap = _asMap(root['defaults']);
_validateKeys(
scope: 'defaults',
map: defaultsMap,
allowed: {
'defaultBranch',
'default_branch',
'sessionPrefix',
'session_prefix',
},
);
final defaultAssistantMap = <String, dynamic>{};
final defaults = IssueAssistantConfig.fromMap(defaultAssistantMap);
final projects = _loadAoSubsetProjects(
root['projects'],
defaultsMap: defaultsMap,
defaultAssistant: defaults,
defaultAssistantMap: defaultAssistantMap,
);
return AppConfig(
configPath: p.normalize(p.absolute(path)),
dataDir: _expandOptionalPath(root['dataDir']?.toString()),
worktreeDir: _expandOptionalPath(root['worktreeDir']?.toString()),
defaults: defaults,
projects: projects,
);
}
static Map<String, ProjectConfig> _loadProjects(
Object? projectsNode, {
required IssueAssistantConfig defaultAssistant,
required Map<String, dynamic> defaultAssistantMap,
}) {
if (projectsNode is! YamlMap || projectsNode.isEmpty) {
throw const FormatException('projects must be a non-empty map.');
}
@ -39,16 +112,56 @@ class AppConfig {
projects[key] = ProjectConfig.fromMap(
key: key,
map: value,
defaultAssistant: defaults,
defaultAssistant: defaultAssistant,
defaultAssistantMap: defaultAssistantMap,
);
}
return projects;
}
return AppConfig(
configPath: p.normalize(p.absolute(path)),
defaults: defaults,
projects: projects,
);
static Map<String, ProjectConfig> _loadAoSubsetProjects(
Object? projectsNode, {
required Map<String, dynamic> defaultsMap,
required IssueAssistantConfig defaultAssistant,
required Map<String, dynamic> defaultAssistantMap,
}) {
if (projectsNode is! YamlMap || projectsNode.isEmpty) {
throw const FormatException('projects must be a non-empty map.');
}
final projects = <String, ProjectConfig>{};
for (final entry in projectsNode.entries) {
final key = entry.key.toString();
final value = _asMap(entry.value);
_validateKeys(
scope: 'projects.$key',
map: value,
allowed: {
'repo',
'path',
'defaultBranch',
'default_branch',
'sessionPrefix',
'session_prefix',
},
);
final merged = <String, dynamic>{...defaultsMap, ...value};
projects[key] = ProjectConfig.fromMap(
key: key,
map: merged,
defaultAssistant: defaultAssistant,
defaultAssistantMap: defaultAssistantMap,
);
}
return projects;
}
static _ConfigDialect _detectDialect(Map<String, dynamic> root) {
final defaultsMap = _asMap(root['defaults']);
if (defaultsMap.containsKey('issueAssistant')) {
return _ConfigDialect.current;
}
return _ConfigDialect.aoSubset;
}
static Map<String, dynamic> _asMap(Object? value) {
@ -68,6 +181,18 @@ class AppConfig {
throw FormatException('Expected map, got ${value.runtimeType}.');
}
static void _validateKeys({
required String scope,
required Map<String, dynamic> map,
required Set<String> allowed,
}) {
for (final key in map.keys) {
if (!allowed.contains(key)) {
throw FormatException('Unsupported config field: $scope.$key');
}
}
}
}
class ProjectConfig {
@ -237,11 +362,10 @@ class CliResponderConfig {
throw FormatException('Responder "$id" must define command.');
}
final args =
(map['args'] as List?)
?.map((item) => item.toString())
.toList(growable: false) ??
const <String>[];
final args = _normalizeArgs(
(map['args'] as List?)?.map((item) => item.toString()) ??
const <String>[],
);
final env = AppConfig._asMap(
map['env'],
).map((key, value) => MapEntry(key, value.toString()));
@ -257,6 +381,12 @@ class CliResponderConfig {
),
);
}
static List<String> _normalizeArgs(Iterable<String> args) {
return args
.map((arg) => arg == '--json-output' ? '--json' : arg)
.toList(growable: false);
}
}
enum AssistantCapability {
@ -298,6 +428,13 @@ String _expandHome(String path) {
return p.normalize(p.join(home, path.substring(2)));
}
String? _expandOptionalPath(String? path) {
if (path == null || path.isEmpty) {
return null;
}
return _expandHome(path);
}
String _deriveSessionPrefix(String key) {
if (key.contains('-')) {
return key
@ -319,3 +456,5 @@ String _deriveSessionPrefix(String key) {
return key.substring(0, key.length < 3 ? key.length : 3).toLowerCase();
}
enum _ConfigDialect { current, aoSubset }

View File

@ -6,6 +6,8 @@ import 'package:path/path.dart' as p;
part 'database.g.dart';
enum JobStatus { queued, running, completed, failed, skipped }
class ProjectStates extends Table {
TextColumn get projectKey => text()();
TextColumn get repo => text()();
@ -39,7 +41,7 @@ class Jobs extends Table {
IntColumn get issueNumber => integer()();
TextColumn get fingerprint => text()();
TextColumn get trigger => text()();
TextColumn get status => text()();
TextColumn get status => textEnum<JobStatus>()();
TextColumn get createdAt => text()();
TextColumn get updatedAt => text()();
}
@ -179,7 +181,7 @@ class AppDatabase extends _$AppDatabase {
required int issueNumber,
required String fingerprint,
required String trigger,
required String status,
required JobStatus status,
}) {
final now = DateTime.now().toUtc().toIso8601String();
return into(jobs).insert(
@ -195,7 +197,7 @@ class AppDatabase extends _$AppDatabase {
);
}
Future<void> updateJobStatus(int jobId, String status) {
Future<void> updateJobStatus(int jobId, JobStatus status) {
return (update(jobs)..where((tbl) => tbl.id.equals(jobId))).write(
JobsCompanion(
status: Value(status),

View File

@ -1004,15 +1004,15 @@ class $JobsTable extends Jobs with TableInfo<$JobsTable, Job> {
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _statusMeta = const VerificationMeta('status');
@override
late final GeneratedColumn<String> status = GeneratedColumn<String>(
'status',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
late final GeneratedColumnWithTypeConverter<JobStatus, String> status =
GeneratedColumn<String>(
'status',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
).withConverter<JobStatus>($JobsTable.$converterstatus);
static const VerificationMeta _createdAtMeta = const VerificationMeta(
'createdAt',
);
@ -1099,14 +1099,6 @@ class $JobsTable extends Jobs with TableInfo<$JobsTable, Job> {
} else if (isInserting) {
context.missing(_triggerMeta);
}
if (data.containsKey('status')) {
context.handle(
_statusMeta,
status.isAcceptableOrUnknown(data['status']!, _statusMeta),
);
} else if (isInserting) {
context.missing(_statusMeta);
}
if (data.containsKey('created_at')) {
context.handle(
_createdAtMeta,
@ -1152,10 +1144,12 @@ class $JobsTable extends Jobs with TableInfo<$JobsTable, Job> {
DriftSqlType.string,
data['${effectivePrefix}trigger'],
)!,
status: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}status'],
)!,
status: $JobsTable.$converterstatus.fromSql(
attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}status'],
)!,
),
createdAt: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}created_at'],
@ -1171,6 +1165,9 @@ class $JobsTable extends Jobs with TableInfo<$JobsTable, Job> {
$JobsTable createAlias(String alias) {
return $JobsTable(attachedDatabase, alias);
}
static JsonTypeConverter2<JobStatus, String, String> $converterstatus =
const EnumNameConverter<JobStatus>(JobStatus.values);
}
class Job extends DataClass implements Insertable<Job> {
@ -1179,7 +1176,7 @@ class Job extends DataClass implements Insertable<Job> {
final int issueNumber;
final String fingerprint;
final String trigger;
final String status;
final JobStatus status;
final String createdAt;
final String updatedAt;
const Job({
@ -1200,7 +1197,11 @@ class Job extends DataClass implements Insertable<Job> {
map['issue_number'] = Variable<int>(issueNumber);
map['fingerprint'] = Variable<String>(fingerprint);
map['trigger'] = Variable<String>(trigger);
map['status'] = Variable<String>(status);
{
map['status'] = Variable<String>(
$JobsTable.$converterstatus.toSql(status),
);
}
map['created_at'] = Variable<String>(createdAt);
map['updated_at'] = Variable<String>(updatedAt);
return map;
@ -1230,7 +1231,9 @@ class Job extends DataClass implements Insertable<Job> {
issueNumber: serializer.fromJson<int>(json['issueNumber']),
fingerprint: serializer.fromJson<String>(json['fingerprint']),
trigger: serializer.fromJson<String>(json['trigger']),
status: serializer.fromJson<String>(json['status']),
status: $JobsTable.$converterstatus.fromJson(
serializer.fromJson<String>(json['status']),
),
createdAt: serializer.fromJson<String>(json['createdAt']),
updatedAt: serializer.fromJson<String>(json['updatedAt']),
);
@ -1244,7 +1247,9 @@ class Job extends DataClass implements Insertable<Job> {
'issueNumber': serializer.toJson<int>(issueNumber),
'fingerprint': serializer.toJson<String>(fingerprint),
'trigger': serializer.toJson<String>(trigger),
'status': serializer.toJson<String>(status),
'status': serializer.toJson<String>(
$JobsTable.$converterstatus.toJson(status),
),
'createdAt': serializer.toJson<String>(createdAt),
'updatedAt': serializer.toJson<String>(updatedAt),
};
@ -1256,7 +1261,7 @@ class Job extends DataClass implements Insertable<Job> {
int? issueNumber,
String? fingerprint,
String? trigger,
String? status,
JobStatus? status,
String? createdAt,
String? updatedAt,
}) => Job(
@ -1334,7 +1339,7 @@ class JobsCompanion extends UpdateCompanion<Job> {
final Value<int> issueNumber;
final Value<String> fingerprint;
final Value<String> trigger;
final Value<String> status;
final Value<JobStatus> status;
final Value<String> createdAt;
final Value<String> updatedAt;
const JobsCompanion({
@ -1353,7 +1358,7 @@ class JobsCompanion extends UpdateCompanion<Job> {
required int issueNumber,
required String fingerprint,
required String trigger,
required String status,
required JobStatus status,
required String createdAt,
required String updatedAt,
}) : projectKey = Value(projectKey),
@ -1391,7 +1396,7 @@ class JobsCompanion extends UpdateCompanion<Job> {
Value<int>? issueNumber,
Value<String>? fingerprint,
Value<String>? trigger,
Value<String>? status,
Value<JobStatus>? status,
Value<String>? createdAt,
Value<String>? updatedAt,
}) {
@ -1426,7 +1431,9 @@ class JobsCompanion extends UpdateCompanion<Job> {
map['trigger'] = Variable<String>(trigger.value);
}
if (status.present) {
map['status'] = Variable<String>(status.value);
map['status'] = Variable<String>(
$JobsTable.$converterstatus.toSql(status.value),
);
}
if (createdAt.present) {
map['created_at'] = Variable<String>(createdAt.value);
@ -3500,7 +3507,7 @@ typedef $$JobsTableCreateCompanionBuilder =
required int issueNumber,
required String fingerprint,
required String trigger,
required String status,
required JobStatus status,
required String createdAt,
required String updatedAt,
});
@ -3511,7 +3518,7 @@ typedef $$JobsTableUpdateCompanionBuilder =
Value<int> issueNumber,
Value<String> fingerprint,
Value<String> trigger,
Value<String> status,
Value<JobStatus> status,
Value<String> createdAt,
Value<String> updatedAt,
});
@ -3549,10 +3556,11 @@ class $$JobsTableFilterComposer extends Composer<_$AppDatabase, $JobsTable> {
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get status => $composableBuilder(
column: $table.status,
builder: (column) => ColumnFilters(column),
);
ColumnWithTypeConverterFilters<JobStatus, JobStatus, String> get status =>
$composableBuilder(
column: $table.status,
builder: (column) => ColumnWithTypeConverterFilters(column),
);
ColumnFilters<String> get createdAt => $composableBuilder(
column: $table.createdAt,
@ -3644,7 +3652,7 @@ class $$JobsTableAnnotationComposer
GeneratedColumn<String> get trigger =>
$composableBuilder(column: $table.trigger, builder: (column) => column);
GeneratedColumn<String> get status =>
GeneratedColumnWithTypeConverter<JobStatus, String> get status =>
$composableBuilder(column: $table.status, builder: (column) => column);
GeneratedColumn<String> get createdAt =>
@ -3687,7 +3695,7 @@ class $$JobsTableTableManager
Value<int> issueNumber = const Value.absent(),
Value<String> fingerprint = const Value.absent(),
Value<String> trigger = const Value.absent(),
Value<String> status = const Value.absent(),
Value<JobStatus> status = const Value.absent(),
Value<String> createdAt = const Value.absent(),
Value<String> updatedAt = const Value.absent(),
}) => JobsCompanion(
@ -3707,7 +3715,7 @@ class $$JobsTableTableManager
required int issueNumber,
required String fingerprint,
required String trigger,
required String status,
required JobStatus status,
required String createdAt,
required String updatedAt,
}) => JobsCompanion.insert(

View File

@ -1,7 +1,9 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:logging/logging.dart';
import 'cli_responder.dart';
import 'config.dart';
@ -10,6 +12,8 @@ import 'github_client.dart';
import 'workspace_manager.dart';
class IssueAssistantApp {
static final Logger _logger = Logger('code_work_spawner.app');
IssueAssistantApp._({
required this.config,
required this.database,
@ -35,7 +39,7 @@ class IssueAssistantApp {
database: database,
githubClient: GitHubClient(ghCommand: ghCommand),
responderRunner: CliResponderRunner(),
workspaceManager: WorkspaceManager(),
workspaceManager: WorkspaceManager(worktreeRoot: config.worktreeDir),
);
}
@ -47,12 +51,15 @@ class IssueAssistantApp {
}
Future<void> runOnce() async {
_log('starting poll cycle projects=${config.projects.length}');
for (final project in config.projects.values) {
if (!project.issueAssistant.enabled) {
_log('skip project=${project.key} reason=issue_assistant_disabled');
continue;
}
await _pollProject(project);
}
_log('poll cycle completed');
}
Future<void> close() => database.close();
@ -70,14 +77,22 @@ class IssueAssistantApp {
Future<void> _pollProject(ProjectConfig project) async {
final pollRunId = await database.startPollRun(project.key);
try {
final repoDirectory = Directory(project.path);
final repoExists = await repoDirectory.exists();
final projectState = await database.findProjectState(project.key);
final since = projectState?.lastSeenUpdatedAt == null
? null
: DateTime.parse(projectState!.lastSeenUpdatedAt!);
_log(
'poll project=${project.key} repo=${project.repo} '
'path=${project.path} path_exists=$repoExists '
'since=${since?.toUtc().toIso8601String() ?? "initial"}',
);
final issues = await githubClient.fetchUpdatedIssues(
repo: project.repo,
since: since,
);
_log('project=${project.key} fetched_issues=${issues.length}');
DateTime? latestSeen = since;
for (final issue in issues) {
@ -93,12 +108,17 @@ class IssueAssistantApp {
lastSeenUpdatedAt: latestSeen,
);
await database.finishPollRun(pollRunId: pollRunId, success: true);
_log(
'poll project=${project.key} completed latest_seen='
'${latestSeen?.toUtc().toIso8601String() ?? "unchanged"}',
);
} catch (error) {
await database.finishPollRun(
pollRunId: pollRunId,
success: false,
error: error.toString(),
);
_log('poll project=${project.key} failed error=$error');
rethrow;
}
}
@ -114,16 +134,23 @@ class IssueAssistantApp {
final fingerprint = _fingerprintThread(thread);
final record = await database.findIssueThread(project.key, issue.number);
if (record?.lastEvaluatedFingerprint == fingerprint) {
_log(
'skip issue=${project.key}#${issue.number} reason=fingerprint_unchanged',
);
return;
}
final trigger = _determineTrigger(project, thread);
_log(
'evaluate issue=${project.key}#${issue.number} '
'comments=${thread.comments.length} trigger=$trigger',
);
final jobId = await database.createJob(
projectKey: project.key,
issueNumber: issue.number,
fingerprint: fingerprint,
trigger: trigger,
status: 'queued',
status: JobStatus.queued,
);
await database.upsertIssueThread(
@ -137,7 +164,7 @@ class IssueAssistantApp {
lastCommentUrl: record?.lastCommentUrl,
);
await database.updateJobStatus(jobId, 'running');
await database.updateJobStatus(jobId, JobStatus.running);
final forceReply = trigger == 'mention';
final prompt = _buildPrompt(
project: project,
@ -145,6 +172,25 @@ class IssueAssistantApp {
trigger: trigger,
);
if (project.issueAssistant.responders.isEmpty) {
_logError(
'issue=${project.key}#${issue.number} '
'skipped no_responders_configured',
);
await database.upsertIssueThread(
projectKey: project.key,
issueNumber: issue.number,
fingerprint: fingerprint,
updatedAt: issue.updatedAt,
lastEvaluatedFingerprint: fingerprint,
lastDecision: record?.lastDecision,
lastCommentId: record?.lastCommentId,
lastCommentUrl: record?.lastCommentUrl,
);
await database.updateJobStatus(jobId, JobStatus.skipped);
return;
}
ResponderResult? selectedResult;
String? workspacePath;
Object? lastError;
@ -154,6 +200,11 @@ class IssueAssistantApp {
workspacePath = needsWorktree
? await workspaceManager.createEphemeralWorktree(project.path)
: 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(
@ -174,10 +225,19 @@ class IssueAssistantApp {
stdout: result.rawStdout,
stderr: result.rawStderr,
);
_log(
'responder=${responder.id}b issue=${project.key}#${issue.number}, '
'title="${thread.issue.title}", '
'decision=${result.decision}, mode=${result.mode}',
);
selectedResult = result;
break;
} catch (error) {
lastError = error;
_log(
'responder=${responder.id} issue=${project.key}#${issue.number} '
'failed error=$error',
);
await database.addExecutionRun(
jobId: jobId,
responderId: responder.id,
@ -196,7 +256,10 @@ class IssueAssistantApp {
}
if (selectedResult == null) {
await database.updateJobStatus(jobId, 'failed');
await database.updateJobStatus(jobId, JobStatus.failed);
_log(
'issue=${project.key}#${issue.number} failed no_responder_succeeded',
);
throw StateError(
'All responders failed for ${project.key}#${issue.number}: $lastError',
);
@ -213,7 +276,8 @@ class IssueAssistantApp {
lastCommentId: record?.lastCommentId,
lastCommentUrl: record?.lastCommentUrl,
);
await database.updateJobStatus(jobId, 'completed');
await database.updateJobStatus(jobId, JobStatus.completed);
_log('issue=${project.key}#${issue.number} completed decision=no_reply');
return;
}
@ -246,7 +310,8 @@ class IssueAssistantApp {
lastCommentId: posted.id,
lastCommentUrl: posted.url,
);
await database.updateJobStatus(jobId, 'completed');
await database.updateJobStatus(jobId, JobStatus.completed);
_log('issue=${project.key}#${issue.number} posted_comment_id=${posted.id}');
}
String _buildPrompt({
@ -320,4 +385,12 @@ class IssueAssistantApp {
}) {
return '$markdown\n\n<!-- $tag:$fingerprint -->';
}
void _log(String message) {
_logger.info(message);
}
void _logError(String message) {
_logger.severe(message);
}
}

View File

@ -3,10 +3,24 @@ import 'dart:io';
import 'package:path/path.dart' as p;
class WorkspaceManager {
WorkspaceManager({this.worktreeRoot});
final String? worktreeRoot;
static const Set<String> _gitContextEnvironmentKeys = {
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
'GIT_COMMON_DIR',
'GIT_DIR',
'GIT_IMPLICIT_WORK_TREE',
'GIT_INDEX_FILE',
'GIT_OBJECT_DIRECTORY',
'GIT_PREFIX',
'GIT_WORK_TREE',
};
Future<String> createEphemeralWorktree(String projectPath) async {
final parent = await Directory.systemTemp.createTemp('cws-worktree-');
final parent = await _createWorktreeParent();
final workspacePath = p.join(parent.path, 'repo');
final result = await Process.run('git', [
final command = [
'-C',
projectPath,
'worktree',
@ -14,20 +28,13 @@ class WorkspaceManager {
'--detach',
workspacePath,
'HEAD',
]);
];
final result = await _runGit(command);
if (result.exitCode != 0) {
throw ProcessException(
'git',
[
'-C',
projectPath,
'worktree',
'add',
'--detach',
workspacePath,
'HEAD',
],
command,
'${result.stdout}\n${result.stderr}',
result.exitCode,
);
@ -36,8 +43,23 @@ class WorkspaceManager {
return workspacePath;
}
Future<Directory> _createWorktreeParent() async {
if (worktreeRoot == null) {
return Directory.systemTemp.createTemp('cws-worktree-');
}
final root = Directory(worktreeRoot!);
await root.create(recursive: true);
return Directory(
p.join(
root.path,
'cws-worktree-${DateTime.now().microsecondsSinceEpoch}',
),
)..createSync();
}
Future<void> disposeEphemeralWorktree(String workspacePath) async {
final result = await Process.run('git', [
final result = await _runGit([
'-C',
workspacePath,
'worktree',
@ -59,4 +81,15 @@ class WorkspaceManager {
await tempRoot.delete(recursive: true);
}
}
Future<ProcessResult> _runGit(List<String> arguments) {
final environment = Map<String, String>.from(Platform.environment)
..removeWhere((key, _) => _gitContextEnvironmentKeys.contains(key));
return Process.run(
'git',
arguments,
environment: environment,
includeParentEnvironment: false,
);
}
}

View File

@ -14,6 +14,10 @@ dependencies:
path: ^1.9.0
sqlite3: ^2.9.3
yaml: ^3.1.3
result_dart: ^2.1.1
dotenv: ^4.2.0
logging: ^1.3.0
meta: ^1.16.0
dev_dependencies:
build_runner: ^2.6.0

View File

@ -66,6 +66,161 @@ projects:
'project {{project_key}}',
);
});
/// ```gherkin
/// Scenario: Normalize legacy codex json flag
/// Given an orchestrator config that still uses the legacy codex --json-output flag
/// When loading the app config from disk
/// Then the responder args are normalized to the current codex --json flag
/// ```
test('Normalize legacy codex json flag', () async {
// Given an orchestrator config that still uses the legacy codex --json-output flag.
final tempDir = await Directory.systemTemp.createTemp('cws-codex-args-');
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
defaults:
issueAssistant:
responders:
- id: codex
command: codex
args: ["exec", "--json-output"]
projects:
sample:
repo: owner/sample
path: ${tempDir.path}
''');
// When loading the app config from disk.
final config = await AppConfig.load(configFile.path);
// Then the responder args are normalized to the current codex --json flag.
expect(config.projects['sample']!.issueAssistant.responders.single.args, [
'exec',
'--json',
]);
});
/// ```gherkin
/// Scenario: Load AO subset config with shared worktree root
/// Given an AO style shortcut config with dataDir, worktreeDir, and project defaults
/// When loading the app config from disk
/// Then the app exposes the configured data and worktree directories
/// And each project inherits the supported shared defaults
/// ```
test('Load AO subset config with shared worktree root', () async {
// Given an AO style shortcut config with dataDir, worktreeDir, and project defaults.
final tempDir = await Directory.systemTemp.createTemp('cws-ao-subset-');
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
dataDir: ~/.agent-orchestrator
worktreeDir: ~/.worktrees
defaults:
defaultBranch: trunk
sessionPrefix: ao
projects:
sample:
repo: owner/sample
path: ${tempDir.path}
''');
// When loading the app config from disk.
final config = await AppConfig.load(configFile.path);
// Then the app exposes the configured data and worktree directories.
expect(
config.dataDir,
p.join(Platform.environment['HOME']!, '.agent-orchestrator'),
);
expect(
config.worktreeDir,
p.join(Platform.environment['HOME']!, '.worktrees'),
);
// And each project inherits the supported shared defaults.
expect(config.projects['sample'], isNotNull);
expect(config.projects['sample']!.defaultBranch, 'trunk');
expect(config.projects['sample']!.sessionPrefix, 'ao');
expect(config.projects['sample']!.issueAssistant.enabled, isTrue);
});
/// ```gherkin
/// Scenario: Reject unsupported AO fields
/// Given an AO style config that includes an unsupported field
/// When loading the app config from disk
/// Then loading fails with a clear config error
/// ```
test('Reject unsupported AO fields', () async {
// Given an AO style config that includes an unsupported field.
final tempDir = await Directory.systemTemp.createTemp('cws-ao-invalid-');
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
worktreeDir: ~/.worktrees
projects:
sample:
repo: owner/sample
path: ${tempDir.path}
tracker:
plugin: github
''');
// When loading the app config from disk.
final load = AppConfig.load(configFile.path);
// Then loading fails with a clear config error.
await expectLater(
load,
throwsA(
isA<FormatException>().having(
(error) => error.message,
'message',
contains('Unsupported config field: projects.sample.tracker'),
),
),
);
});
});
/// ```gherkin
/// Feature: Worktree management
///
/// As a maintainer running bug verification jobs
/// I want ephemeral git worktrees to respect the configured worktree root
/// So that temporary workspaces are created in a predictable location
/// ```
group('WorkspaceManager.createEphemeralWorktree', () {
/// ```gherkin
/// Scenario: Create and dispose a worktree under the configured root
/// Given a git repository and a dedicated worktree root directory
/// When creating and then disposing an ephemeral worktree
/// Then the worktree is created beneath the configured root
/// And cleanup removes the generated worktree directory without deleting the root
/// ```
test('Create and dispose a worktree under the configured root', () async {
// Given a git repository and a dedicated worktree root directory.
final sandbox = await Directory.systemTemp.createTemp(
'cws-worktree-root-',
);
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
final worktreeRoot = await Directory(
p.join(sandbox.path, 'worktrees'),
).create();
await _initGitRepo(repoDir);
final manager = WorkspaceManager(worktreeRoot: worktreeRoot.path);
// When creating and then disposing an ephemeral worktree.
final workspacePath = await manager.createEphemeralWorktree(repoDir.path);
final workspaceDirectory = Directory(workspacePath);
final parentDirectory = Directory(p.dirname(workspacePath));
await manager.disposeEphemeralWorktree(workspacePath);
// Then the worktree is created beneath the configured root.
expect(p.isWithin(worktreeRoot.path, workspacePath), isTrue);
// And cleanup removes the generated worktree directory without deleting the root.
expect(await workspaceDirectory.exists(), isFalse);
expect(await parentDirectory.exists(), isFalse);
expect(await worktreeRoot.exists(), isTrue);
});
});
/// ```gherkin
@ -268,6 +423,181 @@ projects:
expect(thread.lastCommentId, isNull);
},
);
/// ```gherkin
/// Scenario: Accept codex style jsonl responder output
/// Given a temporary project checkout that can be used as the local repo path
/// And GitHub issue data containing an explicit assistant mention
/// And a responder that emits codex style jsonl events instead of a single json object
/// And an orchestrator-style config pointing to the fake repo and responder
/// When the app processes one polling cycle
/// Then it extracts the final agent message and posts one reply
/// ```
test('Accept codex style jsonl responder output', () async {
// Given a temporary project checkout that can be used as the local repo path.
final sandbox = await Directory.systemTemp.createTemp('cws-jsonl-run-');
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
await _initGitRepo(repoDir);
// And GitHub issue data containing an explicit assistant mention.
final ghScript = File(p.join(sandbox.path, 'gh'));
final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
final issueData = [
{
'number': 18,
'title': 'Need help',
'body': 'Please review the plan.',
'state': 'open',
'html_url': 'https://example.test/issues/18',
'updated_at': '2026-04-04T15:00:00Z',
'user': {'login': 'reporter'},
'labels': const [],
},
];
final commentsData = [
{
'id': 180,
'body': '@helper can you respond?',
'html_url': 'https://example.test/issues/18#issuecomment-180',
'created_at': '2026-04-04T15:00:00Z',
'updated_at': '2026-04-04T15:00:00Z',
'user': {'login': 'reporter'},
},
];
await _writeFakeGhScript(
ghScript: ghScript,
issueListResponse: issueData,
commentResponses: {18: commentsData},
postCommentIssueNumber: 18,
ghLog: ghLog,
);
// And a responder that emits codex style jsonl events instead of a single json object.
final responderScript = File(p.join(sandbox.path, 'responder-jsonl.sh'));
await _writeJsonlResponderScript(responderScript, {
'decision': 'reply',
'mode': 'planning',
'markdown': 'Codex JSONL reply',
'summary': 'parsed event stream',
});
// 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 it extracts the final agent message and posts one reply.
final logLines = await ghLog.readAsLines();
expect(
logLines
.where(
(line) => line.contains('repos/owner/sample/issues/18/comments'),
)
.length,
2,
);
});
/// ```gherkin
/// Scenario: Skip issue evaluation when no responders are configured
/// Given a temporary project checkout that can be used as the local repo path
/// And GitHub issue data with a comment that would otherwise be evaluated
/// And an AO subset config that enables the assistant without defining responders
/// When the app processes one polling cycle
/// Then it completes without throwing an error
/// And it records the issue thread as evaluated without a responder decision
/// And it stores the job status as skipped
/// ```
test('Skip issue evaluation when no responders are configured', () async {
// Given a temporary project checkout that can be used as the local repo path.
final sandbox = await Directory.systemTemp.createTemp(
'cws-no-responders-',
);
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
await _initGitRepo(repoDir);
// And GitHub issue data with a comment that would otherwise be evaluated.
final ghScript = File(p.join(sandbox.path, 'gh'));
final issueData = [
{
'number': 7,
'title': 'Need help',
'body': 'Please review this thread.',
'state': 'open',
'html_url': 'https://example.test/issues/7',
'updated_at': '2026-04-04T14:00:00Z',
'user': {'login': 'reporter'},
'labels': const [],
},
];
final commentsData = [
{
'id': 70,
'body': '@helper can you take a look?',
'html_url': 'https://example.test/issues/7#issuecomment-70',
'created_at': '2026-04-04T14:00:00Z',
'updated_at': '2026-04-04T14:00:00Z',
'user': {'login': 'reporter'},
},
];
await _writeFakeGhScript(
ghScript: ghScript,
issueListResponse: issueData,
commentResponses: {7: commentsData},
);
// And an AO subset config that enables the assistant without defining responders.
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
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();
final thread = await app.database.findIssueThread('sample', 7);
final jobs = await app.database.select(app.database.jobs).get();
await app.close();
// Then it completes without throwing an error.
expect(thread, isNotNull);
// And it records the issue thread as evaluated without a responder decision.
expect(thread!.lastDecision, isNull);
expect(thread.lastEvaluatedFingerprint, thread.fingerprint);
// And it stores the job status as skipped.
expect(jobs, hasLength(1));
expect(jobs.single.status, JobStatus.skipped);
});
});
}
@ -353,23 +683,62 @@ JSON
await Process.run('chmod', ['+x', responderScript.path]);
}
Future<void> _writeJsonlResponderScript(
File responderScript,
Map<String, Object?> response,
) async {
await responderScript.writeAsString('''#!/usr/bin/env bash
set -euo pipefail
cat >/dev/null
cat <<'JSON'
{"type":"thread.started","thread_id":"test-thread"}
{"type":"turn.started"}
{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":${jsonEncode(jsonEncode(response))}}}
{"type":"turn.completed"}
JSON
''');
await Process.run('chmod', ['+x', responderScript.path]);
}
Future<void> _initGitRepo(Directory directory) async {
await Process.run('git', ['init'], workingDirectory: directory.path);
await Process.run('git', [
await _runGit(['init'], workingDirectory: directory.path);
await _runGit([
'config',
'user.email',
'test@example.com',
], workingDirectory: directory.path);
await Process.run('git', [
await _runGit([
'config',
'user.name',
'Test User',
], workingDirectory: directory.path);
await File(p.join(directory.path, 'README.md')).writeAsString('repo');
await Process.run('git', ['add', '.'], workingDirectory: directory.path);
await Process.run('git', [
'commit',
'-m',
'init',
], workingDirectory: directory.path);
await _runGit(['add', '.'], workingDirectory: directory.path);
await _runGit(['commit', '-m', 'init'], workingDirectory: directory.path);
}
const Set<String> _gitContextEnvironmentKeys = {
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
'GIT_COMMON_DIR',
'GIT_DIR',
'GIT_IMPLICIT_WORK_TREE',
'GIT_INDEX_FILE',
'GIT_OBJECT_DIRECTORY',
'GIT_PREFIX',
'GIT_WORK_TREE',
};
Future<ProcessResult> _runGit(
List<String> arguments, {
required String workingDirectory,
}) {
final environment = Map<String, String>.from(Platform.environment)
..removeWhere((key, _) => _gitContextEnvironmentKeys.contains(key));
return Process.run(
'git',
arguments,
workingDirectory: workingDirectory,
environment: environment,
includeParentEnvironment: false,
);
}