App-karr
view release on metacpan or search on metacpan
t/210-foundation-coordinator.t view on Meta::CPAN
my $file = $dir->child('config.yml');
$file->spew_utf8( YAML::XS::Dump( \%data ) );
return $file;
}
sub write_assignment {
my ( $cfg, $data ) = @_;
path($cfg)->sibling('assignment.yml')->spew_utf8( YAML::XS::Dump($data) );
return;
}
sub set_agents_state {
my ( $cfg, $data ) = @_;
path($cfg)->sibling('agents.state')->spew_utf8( json_encode($data) );
return;
}
# Never undef and never a nested deref on a missing key: a mutation that
# stops recording availability must FAIL a test, not die before it runs one.
sub agents_state {
my ( $cfg ) = @_;
my $file = path($cfg)->sibling('agents.state');
return {} unless $file->exists;
my $data = json_decode( $file->slurp_utf8 );
return ref $data eq 'HASH' ? $data : {};
}
sub agent_record {
my ( $cfg, $name ) = @_;
return agents_state($cfg)->{$name} // {};
}
my @ALIVE; # collaborators hold their foundation weakly
sub foundation {
my ( $cfg, %args ) = @_;
my $f = App::karr::Foundation->new( config => "$cfg", %args );
push @ALIVE, $f;
return $f;
}
sub coordinator { return foundation(@_)->_coordinator }
# STDOUT through a real file, not an in-memory scalar: the runner forks and
# dups the child's stdout onto a pipe, and a scalar filehandle has no
# descriptor to dup onto.
sub capture {
my ( $code ) = @_;
my $file = Path::Tiny->tempfile;
open my $save, '>&', \*STDOUT or die "dup stdout: $!";
open STDOUT, '>', "$file" or die "redirect stdout: $!";
binmode STDOUT, ':encoding(UTF-8)';
my ( $ret, $err );
eval { $ret = $code->(); 1 } or $err = $@;
open STDOUT, '>&', $save or die "restore stdout: $!";
close $save;
die $err if defined $err;
return ( $file->slurp_utf8, $ret );
}
# The fake coordination agent. It records what it was given -- its argv (so
# the kind: claude-code contract is visible), the prompt the shell expanded
# for it, its role and its working directory -- and can be told to fail or to
# leave a result object behind.
my $OUT; # where the fake records, set per subtest
sub write_coordinator {
my ( $dir ) = @_;
my $script = path($dir)->child('fake-coordinator.pl');
$script->spew_utf8(<<'PERL');
use strict;
use warnings;
use Path::Tiny qw( path );
my $out = path( $ENV{FAKE_OUT} or die "no FAKE_OUT\n" );
$out->mkpath unless $out->is_dir;
$out->child('runs')->append_utf8("run\n");
my ( $p ) = grep { $ARGV[$_] eq '-p' } 0 .. $#ARGV;
$out->child('prompt')->spew_utf8( defined $p ? ( $ARGV[ $p + 1 ] // '' ) : '' );
$out->child('argv')->spew_utf8( join "\n", @ARGV );
$out->child('env')->spew_utf8( join "\n",
'role=' . ( $ENV{KARR_ROLE} // '' ),
'task=' . ( $ENV{KARR_TASK} // '' ),
'cwd=' . path('.')->realpath );
print "$ENV{FAKE_RESULT}\n" if defined $ENV{FAKE_RESULT} && length $ENV{FAKE_RESULT};
exit( $ENV{FAKE_EXIT} // 0 );
PERL
return qq{$^X -I"$LIB" "$script"};
}
sub fake_out {
my $dir = tempdir( CLEANUP => 1 );
push @KEEP, $dir;
$OUT = $dir;
$ENV{FAKE_OUT} = "$dir";
return $dir;
}
sub coordinator_runs {
my $f = $OUT->child('runs');
return $f->exists ? ( grep { length } split /\n/, $f->slurp_utf8 ) : ();
}
sub coordinator_prompt {
my $f = $OUT->child('prompt');
return $f->exists ? $f->slurp_utf8 : '';
}
sub log_of {
my $f = path( $_[0] )->child('.karr.log');
return $f->exists ? $f->slurp_utf8 : '';
}
sub coordinator_env {
my $f = $OUT->child('env');
return '' unless $f->exists;
return $f->slurp_utf8;
}
# ---------------------------------------------------------------------------
# 1. Which agent is the coordinator
# ---------------------------------------------------------------------------
subtest 'the coordinator is a marked agent definition, not a second config key'
=> sub {
my $cfg = write_config(
agents => {
worker => { command => 'true' },
planner => { command => 'true', kind => 'claude-code',
role => 'coordinator' },
},
);
my $c = coordinator($cfg);
is $c->name, 'planner', 'the definition marked role: coordinator is the one';
ok $c->configured, 'and the fleet has a judgement layer';
my $none = coordinator( write_config( agents => { w => { command => 'true' } } ) );
is $none->name, undef, 'a fleet that marks none has none';
ok !$none->configured, 'and says so';
my $two = coordinator( write_config( agents => {
a => { command => 'true', role => 'coordinator' },
b => { command => 'true', role => 'coordinator' },
} ) );
my $err = do { local $@; eval { $two->name }; $@ };
like $err, qr/both marked/,
'two marked definitions are refused rather than guessed between: "which '
. 'of these is the judgement layer" has no safe default';
my $typo = coordinator( write_config( agents => {
a => { command => 'true', role => 'coordinater' },
} ) );
my $terr = do { local $@; eval { $typo->name }; $@ };
like $terr, qr/unknown role 'coordinater'/,
'and a typo in the marker is a hard error, not a fleet that quietly has '
. 'no judgement layer at all';
};
# ---------------------------------------------------------------------------
# 2. The hot path: a lookup, and no AI in it
# ---------------------------------------------------------------------------
subtest 'the assignment routes a board, and the first working agent wins' => sub {
fake_out();
my $repo = make_repo();
t/210-foundation-coordinator.t view on Meta::CPAN
is( ( $f->_resolve_agent( $other, {} ) )[0], 'default-cmd',
'and a repository the assignment does not name falls through to the '
. 'fleet default exactly as it did before there was an assignment' );
};
subtest 'an assignment naming an agent this machine has not is not fatal' => sub {
my $repo = make_repo();
my $cfg = write_config( agents => {
here => { command => 'here-cmd' },
planner => { command => 'true', role => 'coordinator' },
} );
write_assignment( $cfg,
{ repos => { "$repo" => [ 'elsewhere', 'here' ] } } );
my $f = foundation($cfg);
is( ( $f->_resolve_agent( $repo, {} ) )[0], 'here-cmd',
'an agent this machine does not define is skipped, not refused: agent '
. 'definitions are local and only local, so a table written where more of '
. 'them exist is a normal thing to meet' );
is_deeply [ @{ $f->_coordinator->wanted } ], [],
'and that is not a deviation either';
write_assignment( $cfg, { repos => { "$repo" => ['elsewhere'] } } );
my $f2 = foundation($cfg);
is( ( $f2->_resolve_agent( $repo, {} ) )[0], undef, 'a chain of nothing but '
. 'unknown names routes nothing' );
is scalar @{ $f2->_coordinator->wanted }, 1,
'and that IS a deviation the coordination agent hears about';
};
# ---------------------------------------------------------------------------
# 3. One call per tick, at the end of it
# ---------------------------------------------------------------------------
subtest 'a tick with no assignment calls the coordination agent once' => sub {
my $out = fake_out();
my $hub = make_repo();
my @repo = ( make_repo(), make_repo(), make_repo() );
seed_board( $_, { status => 'todo' } ) for @repo;
my $cfg = write_config(
hub => "$hub",
dirs => [ map { "$_" } @repo ],
routing => "minimax is cheap and does the routine work.\n"
. "Never hand it a release.",
agents => {
minimax => { command => 'minimax-cmd', description => 'cheap and fast' },
planner => { command => write_coordinator($hub), kind => 'claude-code',
role => 'coordinator',
description => 'the one that thinks' },
},
);
my ( $printed ) = capture( sub { foundation($cfg)->run } );
is scalar( coordinator_runs() ), 1,
'THREE boards nobody has routed are ONE call: a tick that met five '
. 'deviations has learned one thing, and five calls would pay five times '
. 'to hear it';
my $prompt = coordinator_prompt();
like $prompt, qr/\Q$_\E: no assignment names this repository/, "prompt names $_"
for @repo;
like $prompt, qr/Never hand it a release/,
'the operator\'s own prose reaches it -- that prose IS the routing '
. 'criterion, and karr never parses it';
like $prompt, qr/cheap and fast/, 'so does each agent\'s description';
like $prompt, qr/\Qminimax\E\s+kind: shell\s+ok/, 'with what is known about it now';
like $prompt, qr/assignment\s+\Q@{[ path($cfg)->sibling('assignment.yml') ]}\E/,
'and the path it is supposed to write';
like $prompt, qr/never names an agent|Name an agent in a chain step/i,
'the boundary that keeps routing out of the shared chain is stated';
my $env = coordinator_env();
like $env, qr/role=coordinator/,
'it runs under its own role, so its karr writes are not an agent\'s '
. 'engagement with a card';
like $env, qr/task=$/m, 'and it is given no ticket: it is not working a card';
like $env, qr{cwd=\Q@{[ path($hub)->realpath ]}\E},
'in the hub, where the chain and the questions it may write live';
like $printed, qr/calling the coordination agent 'planner' for 3 deviation/,
'and the tick says it out loud';
my $log = log_of($hub);
like $log, qr/START role=coordinator agent=planner/,
'the run is in the hub\'s log like every other run karr starts';
like $log, qr/COORDINATION wanted:/, 'together with what it was called for';
};
subtest 'the chain\'s three deviations are one call, after the tick' => sub {
my $out = fake_out();
my $hub = make_repo();
my $repo = make_repo();
my $marker = path($repo)->child('marker');
seed_board( $repo, { status => 'done' } ); # nothing actionable left
my $cfg = write_config(
hub => "$hub",
agents => { planner => { command => write_coordinator($hub),
kind => 'claude-code', role => 'coordinator' } },
);
chain_store($hub)->write_chain( [
{ id => 1, kind => 'plan', note => 'what next?' },
{ id => 2, kind => 'shell', repo => "$repo", command => "echo x >> '$marker'",
precheck => 'board_actionable == yes' },
{ id => 3, kind => 'plan', note => 'and then?' },
] );
my ( $printed, $exit ) = capture( sub { foundation($cfg)->run('chain') } );
is $exit, 0, 'the tick finished';
ok !$marker->exists, 'the stale step did not run';
is scalar( coordinator_runs() ), 1,
'two plan steps and one stale step are ONE call at the end of the tick';
my $prompt = coordinator_prompt();
like $prompt, qr/step 1: kind: plan is not executed here/, 'the plan step is named';
like $prompt, qr/step 3: kind: plan is not executed here/, 'both of them';
like $prompt, qr/step 2: .*precheck/,
'and the stale step, with the precheck that stopped holding';
like $printed, qr/the coordination agent is called at the end of this tick/,
'the tick says a planner is wanted AND that one exists to be called';
};
subtest 'an overdue escalate_to_ai question is one of the deviations' => sub {
my $out = fake_out();
my $hub = make_repo();
my $cfg = write_config(
hub => "$hub",
agents => { planner => { command => write_coordinator($hub),
kind => 'claude-code', role => 'coordinator' } },
);
chain_store($hub)->write_chain( [ { id => 1, kind => 'question' } ] );
my $qid = App::karr::Foundation::Questions->new(
git => App::karr::Git->new( dir => "$hub" ) )->ask(
question => 'which registry?', policy => 'escalate_to_ai',
deadline => '2000-01-01T00:00:00Z', step => 1 );
my ( $printed ) = capture( sub { foundation($cfg)->run('chain') } );
is scalar( coordinator_runs() ), 1, 'the policy that names the agent calls it';
like coordinator_prompt(), qr/step 1: escalate_to_ai on question #\Q$qid\E/,
'naming the question, not merely the step';
is chain_store($hub)->step(1)->{state}, 'pending',
'and the step is left exactly as the planner left it: the question is '
. 'still open, and nothing here answered it on the agent\'s behalf';
};
# ---------------------------------------------------------------------------
# 4. An agent like any other
# ---------------------------------------------------------------------------
subtest 'a coordination agent that fails is marked failing and then waited for'
=> sub {
my $out = fake_out();
my $hub = make_repo();
my $repo = make_repo();
seed_board( $repo, { status => 'todo' } );
my $cfg = write_config(
hub => "$hub",
dirs => [ "$repo" ],
agents => { planner => { command => write_coordinator($hub),
kind => 'claude-code', role => 'coordinator',
probe_every => '30m' } },
);
{
local $ENV{FAKE_EXIT} = 3;
my ( $printed ) = capture( sub { foundation($cfg)->run } );
like $printed, qr/coordination agent 'planner' failed: exit=3/,
'a bad exit is a failure like any other agent\'s';
}
my $state = agent_record( $cfg, 'planner' );
is $state->{state}, 'failing',
'and it goes into the same availability record every board agent uses';
is $state->{last_error}, 'exit=3', 'with what was seen';
cmp_ok $state->{next_attempt} // 0, '>', time + 1500,
'and its own probe_every decides when it is tried again';
# The second tick: the same deviation, an agent that is failing. The place
# that wanted it waits -- which is what karr-foundation did before there was
# a coordination agent at all.
my ( $again ) = capture( sub { foundation($cfg)->run } );
is scalar( coordinator_runs() ), 1, 'it is not called again while it fails';
like $again, qr/the coordination agent 'planner' is failing/,
'and the tick says why nothing was planned';
like $again, qr/the plan waits/, 'the deviation simply keeps waiting';
};
subtest 'the run is classified from its result JSON, never from its transcript'
=> sub {
my $out = fake_out();
my $hub = make_repo();
my $repo = make_repo();
seed_board( $repo, { status => 'todo' } );
my $cfg = write_config(
hub => "$hub",
dirs => [ "$repo" ],
agents => { planner => { command => write_coordinator($hub),
kind => 'claude-code', role => 'coordinator' } },
);
( run in 0.815 second using v1.01-cache-2.11-cpan-aadc1410aed )