App-karr
view release on metacpan or search on metacpan
lib/App/karr/Foundation.pm view on Meta::CPAN
return 1;
}
# Snapshot every task as id => { status, claimed_by, updated, blocked }.
sub _task_states {
my ( $self, $repo ) = @_;
my $git = App::karr::Git->new( dir => "$repo" );
return () unless $git->is_repo;
my $store = App::karr::BoardStore->new( git => $git );
my %states;
for my $t ( $store->load_tasks ) {
next unless $t;
$states{ $t->id } = {
status => $t->status,
claimed_by => ( $t->has_claimed_by ? $t->claimed_by : undef ),
updated => $t->updated,
blocked => ( $t->has_blocked ? 1 : 0 ),
};
}
return %states;
}
sub _has_actionable_tasks {
my ( $self, $repo ) = @_;
my %states = $self->_task_states( $repo );
for my $id ( keys %states ) {
return 1 if $self->_is_actionable( $states{$id} );
}
return 0;
}
# Tasks the agent engaged (claimed / in-progress) but did not move across a
# run â still actionable and byte-identical before/after. These are the only
# tasks that count toward an auto-block.
sub _stuck_tasks {
my ( $self, $before, $after ) = @_;
my @stuck;
for my $id ( sort { $a <=> $b } keys %$after ) {
my $a = $after->{$id};
next unless $self->_is_actionable( $a );
next unless defined $a->{claimed_by} || ( $a->{status} // '' ) eq 'in-progress';
my $b = $before->{$id} or next; # newly created this run â give it grace
next if ( $b->{status} // '' ) ne ( $a->{status} // '' );
next if ( $b->{updated} // '' ) ne ( $a->{updated} // '' );
push @stuck, $id;
}
return @stuck;
}
# ---------------------------------------------------------------------------
# Drain loop
# ---------------------------------------------------------------------------
# Run the agent repeatedly until the board has no actionable tasks left,
# auto-blocking tasks the agent keeps failing on. Returns
# { outcome => progress|idle|common-error|error, exit => N }.
sub _drain_repo {
my ( $self, $repo, $karr, $cmd ) = @_;
my $max_runtime = $karr->{max_runtime} // 1800;
my $max_attempts = $karr->{max_attempts} // 2;
my $max_iter = $karr->{max_iterations} // 50;
my $drain = exists $karr->{drain} ? $karr->{drain} : 1;
my $patterns = $self->_error_patterns( $karr );
# Use the resolved command, not $karr->{command}
$cmd //= $karr->{command};
my $loop_start = time;
my $last_exit = 0;
my $outcome = 'idle';
my $first = 1;
my $iter = 0;
while ( 1 ) {
my %before = $self->_task_states( $repo );
my @actionable = grep { $self->_is_actionable( $before{$_} ) } keys %before;
# Once we have run at least once, stop when the board is drained, the
# wall-clock budget is spent, or we hit the hard iteration cap.
last if !$first && !@actionable;
last if !$first && ( time - $loop_start ) >= $max_runtime;
last if $iter >= $max_iter;
my $hash_before = $self->_ref_hash( $repo ) // '';
my ( $exit, $output ) = $self->_run_command( $repo, $karr, $cmd );
$last_exit = $exit;
$first = 0;
$iter++;
# Common error we can observe (bad exit, timeout, or a known log pattern):
# don't penalize any task â leave the board untouched and back off.
my $err = ( $exit != 0 ) ? "exit=$exit" : undef;
$err //= $self->_match_error( $output, $patterns );
if ( defined $err ) {
$self->_append_log( $repo, "COMMON-ERROR $err" );
$self->_state_set( $repo, last_error => $err );
$outcome = 'common-error';
last;
}
my $hash_after = $self->_ref_hash( $repo ) // '';
my $progressed = ( $hash_before ne $hash_after ) ? 1 : 0;
$outcome = 'progress' if $progressed;
my %after = $self->_task_states( $repo );
my @stuck = $self->_stuck_tasks( \%before, \%after );
# Reset the attempt counter for any task that is no longer stuck
# (advanced, blocked, or gone), then bump/auto-block the stuck ones.
my %is_stuck = map { $_ => 1 } @stuck;
my $attempts = $self->_state_get( $repo, 'attempts' ) // {};
$self->_reset_attempts( $repo, $_ ) for grep { !$is_stuck{$_} } keys %$attempts;
for my $id ( @stuck ) {
my $n = $self->_bump_attempts( $repo, $id );
next if $n < $max_attempts;
$self->_autoblock_task( $repo, $id,
"auto-block: no progress after $n attempts (foundation)" );
$self->_reset_attempts( $repo, $id );
}
lib/App/karr/Foundation.pm view on Meta::CPAN
1;
__END__
=pod
=encoding UTF-8
=head1 NAME
App::karr::Foundation - Single-shot foundation daemon â periodic agent execution across karr boards
=head1 VERSION
version 0.301
=head1 SYNOPSIS
# Typical cron entry â run every 5 minutes
*/5 * * * * /path/to/karr-foundation
# Force a run regardless of board state
karr-foundation --force
# Preview what would run
karr-foundation --dry-run --verbose
# Read-only overview of every board (no agent runs)
karr-foundation --status
=head1 DESCRIPTION
F<karr-foundation> is a single-shot, idempotent CLI meant to be invoked
periodically (cron, systemd-timer, while-loop). It scans configured karr
boards, detects changes or open work, and B<drains> each board by invoking the
configured agent command repeatedly until no actionable task remains.
B<Config file:> C<~/.config/karr-foundation/config.yml> (or C<--config>).
dirs:
- /path/to/repo1
- /path/to/repo2
scan:
- /path/to/parent-dir # finds all direct subdirs that have a .karr file
B<Per-repo .karr file:>
claude: true # synthesize the canonical claude command (opt-in)
claude_bin: claude # binary for claude: true (default: claude)
claude_max_turns: 30 # --max-turns for claude: true (default: 30)
claude_permission_mode: bypassPermissions # (default: bypassPermissions)
prompt: >- # agent instruction, exposed as $PROMPT
Use the karr-coordinator skill: pick the next actionable task and move it.
command: claude -p "$PROMPT" # explicit command; wins over claude: true
on_idle: skip # 'skip' (default) | 'always-run'
max_runtime: 1800 # seconds: per-command SIGKILL (0 = no limit)
drain: true # loop until drained (default) | false for single run
max_attempts: 2 # stalls on one task before auto-block (default: 2)
max_iterations: 50 # hard cap on drain iterations (default: 50)
cooldown_base: 1 # cooldown minutes at level 0 (default: 1)
cooldown_max: 64 # cooldown ceiling in minutes (default: 64)
error_patterns: # extra case-insensitive substrings â common-error
- my custom api error
C<claude>, C<claude_bin>, C<claude_max_turns>, C<claude_permission_mode>,
C<command> and C<prompt>/C<default_prompt> may also be set globally in the
config file; the per-repo F<.karr> value wins.
B<Coordinator and overview.> Agent execution is opt-in â a board runs an agent
only via C<command> or C<< claude: true >>. When B<no> board has an agent
configured, the default action is a read-only B<overview> of every board
(status counts, in-progress/blocked tasks, lock and cooldown state); a human
can use foundation purely to coordinate their own work. C<--status> forces the
overview regardless of configuration.
B<Live output.> When run interactively (TTY) or with C<--verbose>, the agent's
output is streamed to the terminal in real time as foundation reads it; it is
always appended to F<.karr.log> regardless of TTY. To shape what is shown, the
command may emit stream-json and filter it, e.g.:
command: >-
claude -p "$PROMPT"
--output-format stream-json --verbose --include-partial-messages
--permission-mode bypassPermissions --max-turns 10
2>&1 | jq -r 'select(.type == "stream_event") | .event.delta.text // empty'
Set C<max_runtime: 0> in F<.karr> to disable the per-run timeout entirely
(agent runs until completion with no SIGKILL).
B<Drain semantics.> Each iteration runs C<command> once, then classifies the
result from what foundation can observe â exit code, board ref movement, and
the run's captured output:
=over 4
=item * B<progress> â the board changed; keep draining.
=item * B<stall> â a task the agent claimed / left C<in-progress> did not move.
That task's attempt counter is bumped; at C<max_attempts> it is auto-blocked
(C<blocked: auto-block: no progress after N attempts (foundation)>) so it drops
out of the actionable set and the drain can finish. The agent may always set a
better reason itself with C<karr edit --block>; the auto-block is a fallback.
=item * B<common-error> â a non-zero/timeout exit or a C<error_patterns> match
(rate limit, auth, network, 5xx, â¦). No task is penalized; the repo enters an
exponential cooldown (C<cooldown_base> Ã 2^level minutes, capped at
C<cooldown_max>, reset on the next clean run) and is skipped until it expires.
=item * B<idle> â the agent did nothing and grabbed nothing; stop.
=back
All state files are gitignored: C<.karr.state> (board hash, per-task attempts,
cooldown, last error), C<.karr.lock>, C<.karr.log>.
=head1 SUPPORT
=head2 Issues
( run in 0.447 second using v1.01-cache-2.11-cpan-96521ef73a4 )