App-karr

 view release on metacpan or  search on metacpan

lib/App/karr/Foundation.pm  view on Meta::CPAN

  }
  $eng->{seen} = scalar @entries;
  return $eng;
}

# True when the card is the agent's to penalize: unclaimed, or held under a
# name this run's agent itself wrote with. A claim belonging to anybody else —
# a human, another machine's agent, or this agent's own abandoned claim from an
# earlier run — is never ours to auto-block.
sub _agent_holds {
  my ( $self, $state, $claims ) = @_;
  my $owner = $state->{claimed_by};
  return 1 unless defined $owner && length $owner;
  return ( $claims // {} )->{$owner} ? 1 : 0;
}

# Tasks this run's agent engaged but did not move — still actionable, written
# to by the agent during this drain, held by nobody but the agent, and
# byte-identical before/after the last command. These are the only tasks that
# count toward an auto-block.
#
# Engagement is proven, never assumed: without an entry of the agent's own in
# $eng, foundation has no evidence it ever attempted the task, and an
# auto-block would be a destructive write to somebody else's card carrying a
# reason that is factually wrong (#158). So an engagement it cannot establish —
# an agent that does not write through karr, an unreadable log, a stale claim
# nobody touched this run — yields no stuck tasks and no auto-block at all.
# Failing to block a genuinely stuck card only leaves the drain to end on its
# iteration cap; blocking a stranger's card takes their work out of the
# actionable set behind their back.
sub _stuck_tasks {
  my ( $self, $before, $after, $eng ) = @_;
  my $ids    = ( $eng // {} )->{ids}    // {};
  my $claims = ( $eng // {} )->{claims} // {};
  my @stuck;
  for my $id ( sort { $a <=> $b } keys %$after ) {
    my $a = $after->{$id};
    next unless $self->_is_actionable( $a );
    next unless $ids->{$id};                      # the agent never touched it
    next unless $self->_agent_holds( $a, $claims ); # somebody else holds it
    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;

  # What this run's agent engages, accumulated across the whole drain: the
  # iteration that claims a task is the one that moves the board, so the stall
  # only becomes visible one or more iterations later.
  my $eng = $self->_new_engagement( $repo );

  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. The
    # wall-clock check is skipped when max_runtime is 0: that value disables
    # the per-run timeout entirely (documented, Runner.pm), and the drain's
    # budget must not silently inherit the same "no limit" sentinel as a
    # hard zero — `>= 0` is always true after the first iteration and would
    # turn drain: true into a single run (#165). With max_runtime: 0 the
    # drain runs until the board is drained or the iteration cap.
    last if !$first && !@actionable;
    last if !$first && $max_runtime > 0 && ( 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++;

    my $hash_after = $self->_ref_hash( $repo ) // '';
    my $progressed = ( $hash_before ne $hash_after ) ? 1 : 0;

    # Common error we can observe (bad exit, timeout, or a known output
    # pattern): don't penalize any task — leave the board untouched and back
    # off. What the run *did* is asked before what it *printed* (#160): a run
    # that exited 0 and moved the board did work, whatever text went past on
    # the way, and re-reading its own transcript is the one way to lose that
    # work — the drain aborted, the progress was credited to nobody, and the
    # cooldown climbed on every following run because the board still said the
    # same words. So the output is evidence only where there is nothing else:
    # a run that produced no board movement at all. The genuine case the scan
    # exists for looks exactly like that, because an agent that hit a rate
    # limit or a dead key could not move anything.
    my $err;
    if ( $exit != 0 ) {
      $err = "exit=$exit";     # or -1, the timeout — a hard signal, no scan
    }
    else {
      my $seen = $self->_match_error( $output, $patterns );
      if ( defined $seen && $progressed ) {
        # Worth saying once: an agent that reports a rate limit and still gets
        # a card moved is on its last legs, and the operator should hear it
        # from the log rather than from the next run's cooldown.
        $self->_append_log( $repo,
          "NOTE '$seen' in output, but the board moved \x{2014} not treated as an error" );
      }
      else {
        $err = $seen;
      }
    }

    if ( defined $err ) {
      # An exit-0 run that is thrown away is the surprising one; .karr.state
      # would otherwise carry last_exit: 0 next to last_error with nothing
      # anywhere saying why the run did not count.

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.500

=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   # (added to the defaults; matched as written)

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<Board-level disable.> A board can opt out of automated agent runs in its own
karr state — C<foundation.enabled> in C<refs/karr/config>, set with
C<karr disable [--reason "why"]> and cleared with C<karr enable>. Because the
flag is board state it syncs with the board, so every foundation instance on
every machine honours it. A disabled board is skipped B<whole>: the flag is
checked before the agent command is resolved and before the drain decision, so
there is no drain, no auto-block and no agent run. It therefore wins over
C<--command>, the config's C<default_command>, the F<.karr> C<command> and
C<< claude: true >>, and C<--force> does B<not> override it. Use it for a
repository whose backlog is parked (an abandoned project kept for reference)
that a globally configured C<default_command> would otherwise drain. C<--status>
shows such a board with a C<disabled> flag and its reason.

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 B<this run's agent engaged> 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.

B<Engaged> means foundation can prove the agent worked on that card during
B<this> drain: the agent runs with C<KARR_ROLE=agent>, so every C<karr> write
it makes is recorded in the board's own activity log under the C<agent>



( run in 0.629 second using v1.01-cache-2.11-cpan-4ab04211f4c )