App-karr
view release on metacpan or search on metacpan
lib/App/karr/Cmd/Pick.pm view on Meta::CPAN
# ABSTRACT: Atomically find and claim the next available task
package App::karr::Cmd::Pick;
our $VERSION = '0.500';
use Moo;
use MooX::Cmd;
use MooX::Options (
usage_string => 'USAGE: karr pick --claim NAME [--move STATUS] [--status LIST] [--tags LIST]',
);
use App::karr::Role::BoardAccess;
use App::karr::Role::Output;
use App::karr::Role::DependencyCheck;
use App::karr::Task;
use App::karr::Config;
use App::karr::Lock;
use Time::Piece;
with 'App::karr::Role::BoardAccess', 'App::karr::Role::Output',
'App::karr::Role::ClaimTimeout', 'App::karr::Role::DependencyCheck';
option claim => (
is => 'ro',
format => 's',
required => 1,
doc => 'Agent name to claim the task for',
);
option status => (
is => 'ro',
format => 's',
doc => 'Source status(es) to pick from (comma-separated)',
);
option move => (
is => 'ro',
format => 's',
doc => 'Move picked task to this status',
);
option tags => (
is => 'ro',
format => 's',
doc => 'Only pick tasks matching at least one tag',
);
sub execute {
my ($self, $args_ref, $chain_ref) = @_;
$self->sync_before;
$self->require_board;
my $ec = $self->store->effective_config;
my $timeout = $self->_parse_timeout($ec->{claim_timeout} // '1h');
# Before any lock is taken: --move is a plain option value, and a bad one used
# to be discovered only after a task had already been locked and claimed, so
# the pick parked it in a status that is not a column (ticket #54). Pick does
# not go through apply_status_change -- it has its own compare-and-swap loop --
# so the check is here.
App::karr::Config->from_merged($ec)->validate_status($self->move)
if defined $self->move;
# A ranking, not a decision. Every one of these is re-read and re-tested under
# its own lock before anything is written (see EXCLUSIVITY above).
my @tasks = grep { $self->_is_pickable($_, $timeout) } $self->load_tasks;
# Sort by class priority, then by priority. Both axes are driven by the
# board's configured lists -- not by a hardcoded table that only knew the
# four default priorities and classes. A board imported from kanban-md
# can name anything (ticket #149: a `blocker` priority beat a `critical`
# one on a non-default board); picking against the hardcoded table gave
# the wrong card out while `karr list --sort priority` showed the right
# one right next to it.
#
# Convention, matching kanban-md's pick.go: lower class index = more
# urgent class; higher priority index = more urgent priority. So the
# sort key for priority is `(max - priority_index)` -- most-urgent-last
# in the config list comes out first.
my $cfg = App::karr::Config->from_merged($ec);
my @priorities = $cfg->priorities;
my @classes = $cfg->classes;
my %pri_idx; $pri_idx{$priorities[$_]} = $_ for 0 .. $#priorities;
my %cls_idx; $cls_idx{$classes[$_]} = $_ for 0 .. $#classes;
my $max_pri = $#priorities;
my $std_cls_idx = $cls_idx{standard} // 0;
@tasks = sort {
( ($cls_idx{$a->class} // $std_cls_idx) <=> ($cls_idx{$b->class} // $std_cls_idx) )
|| ( ($max_pri - ($pri_idx{$a->priority} // -1))
<=> ($max_pri - ($pri_idx{$b->priority} // -1)) )
|| $a->id <=> $b->id
} @tasks;
unless (@tasks) {
return $self->_nothing_picked("No available tasks to pick.");
}
# Try to lock + claim. A karr board lives in refs/karr/*, which exist only
# inside a Git repo, so reaching this point means we are in one -- the
# locking path is unconditional.
my $lock = App::karr::Lock->new(
git => $self->git,
# Not the 1h _parse_timeout falls back to on its own: see LOCK EXPIRY.
ttl => $self->_parse_timeout($ec->{lock_timeout}, App::karr::Lock->DEFAULT_TTL),
);
my $email = $self->git->git_user_email || $self->claim;
my $picked;
for my $candidate (@tasks) {
my ($ok) = $lock->acquire($candidate->id, $email);
next unless $ok;
# Hold the lock for the claim and nothing else, and give it back on the way
# out either way. Before #45 a die in here left the ref behind for good.
$picked = eval { $self->_claim_under_lock($candidate->id, $timeout) };
my $err = $@;
$lock->release($candidate->id, $email);
die $err if $err;
last if $picked;
}
unless ($picked) {
return $self->_nothing_picked(
"No available tasks to pick (every candidate was locked or taken).");
}
# Both writes have to happen before the push, or they never leave this clone:
# sync_after is the last thing that talks to the remote and it disarms the
# SyncGuard behind it. The lock release above is the same story -- publishing
# a lock and then deleting it locally left the ref on the remote forever (#45).
$self->append_log($self->git,
agent => $self->claim,
action => 'pick',
task_id => $picked->id,
detail => $picked->status,
);
$self->sync_after;
my %dependency = $self->dependency_report( $picked->id );
if ($self->json) {
$self->print_json({ %{ $picked->to_json_hash }, %dependency });
return;
}
printf "Picked task %d: %s (claimed by %s)\n", $picked->id, $picked->title, $self->claim;
printf "Status: %s | Priority: %s | Class: %s\n", $picked->status, $picked->priority, $picked->class;
if ($picked->body) {
print "\n" . $picked->body . "\n";
}
}
# Both empty results in one place, because both of them have to honour --json.
# `pick` is the agent-facing command, so its --json is the one output in karr
# most certain to be machine-parsed -- and it was the one that answered a plain
# English sentence, which a consumer could only meet with a decode error
# (ticket #65). Every other command already had this: `list --json` prints [],
# `archive --json` prints its note as an object.
#
# The payload is deliberately an object rather than a bare `null`: the JSON
# encoder App::karr::Encoding hands out has allow_nonref off, so a top-level
# null cannot be emitted without loosening that for every other command's
# output as well.
#
# The exit status stays 0. kanban-md raises a NothingToPick error and exits
# nonzero, but karr's exit-code contract (ADR 0002) spends 1 on failure and 2
# on usage, and "no work for you right now" is the normal answer to a poll, not
# a failure -- a drain loop that treats it as one stops on its first idle pass.
sub _nothing_picked {
my ($self, $message) = @_;
return $self->print_json({ picked => undef }) if $self->json;
print "$message\n";
return;
}
# The one and only definition of "this card is available to me right now". It is
# a method rather than a chain of greps in execute so that the pre-lock ranking
# and the re-read under the lock cannot drift apart: the second test has to be
# the same test, or moving it inside the lock buys nothing (#86).
sub _is_pickable {
my ($self, $task, $timeout) = @_;
return 0 unless $task;
if ($self->status) {
my %allowed = map { $_ => 1 } split /,/, $self->status;
return 0 unless $allowed{$task->status};
} else {
# The board's own terminal status, not a hardcoded 'done': a board imported
# from kanban-md can end in `shipped`, and pick used to hand those finished
# cards straight back out (ticket #67).
return 0 if $self->store->is_terminal_status($task->status);
}
# `claimed_by: ""` means unclaimed. kanban-md's omitempty writes the key only
# when it is non-empty, but a card it read and rewrote -- or any hand-written
# one -- can carry the empty string, and Moo's predicate calls that "set". So
# every imported kanban-md card looked as though somebody held it, and pick
# reported an empty board while `karr list` showed the work sitting there
# (ticket #59).
# This is the same emptiness test App::karr::Role::ClaimTimeout/check_claim
# already applies; the two have to agree or a task pick refuses is a task
# move happily accepts.
return 0 if $task->has_claimed_by
&& length $task->claimed_by
&& !$self->_claim_expired($task, $timeout);
return 0 if $task->has_blocked;
if ($self->tags) {
my %wanted = map { $_ => 1 } split /,/, $self->tags;
return 0 unless grep { $wanted{$_} } @{$task->tags};
}
return 1;
}
# Claim one candidate, or return false if it is no longer ours to claim.
#
# Everything here reads the card fresh: the ranking in execute was built from a
# snapshot taken before any lock existed and is stale by the time we get here.
# The write is guarded against the OID that same read came from, so losing to
# another agent is a false return rather than a silent overwrite. retry_contended
# separates the two ways a compare-and-swap can fail: the card changed under us
# (re-read, decide again) versus the card is taken (final, move on).
sub _claim_under_lock {
my ($self, $id, $timeout) = @_;
return $self->git->retry_contended("the claim on task $id", sub {
my ($oid, $task) = $self->store->find_task_with_oid($id);
return (0) unless $self->_is_pickable($task, $timeout);
$task->claimed_by($self->claim);
$task->claimed_at(gmtime->datetime . 'Z');
# Outside the --move branch, and before it: on a pick the *claim* is the
# taking-up. `karr pick --claim X` with no --move is the commonest call
# there is, and after it the agent holds the card and starts work --
# whether the status changed on the way says nothing about whether somebody
# should have been told what is still open underneath. Scoped to --move
# only, this left #123's own sentence ("karr pick hands it out regardless")
# true of the very command it was written about.
#
# Without --move the card stays where it is, so the status it stays in is
# the one to judge. That is not a formality: --status is the one way a card
# already in a terminal status reaches this point at all (_is_pickable
# excludes terminal statuses only when --status is absent), and picking up
# a finished card must not lecture about dependencies that stopped
# mattering when it was finished.
#
# Pick does not go through apply_status_change (see EXCLUSIVITY above), so
# this is its own call to the check every other status change gets there.
# And karr deliberately parts company with the reference here: kanban-md
# filters a card with unmet dependencies out of the candidate set outright
# (internal/board/pick.go:69, filterPickDeps), so pick never offers it;
# karr hands it over and warns, because nothing about depends_on blocks
# anything (ticket #123).
$self->check_dependencies( $task, $self->move // $task->status );
if ($self->move) {
my $old_status = $task->status;
$task->status($self->move);
# Same lifecycle rules as every other status change (ticket #68); the
# implementation is on the task, mirroring kanban-md's lifecycle.go.
# The board's own config goes with it, the way apply_status_change hands
# it over everywhere else: without it the terminal question was answered
# for the default board, and a pick --move into this board's final
# column recorded no completion (ticket #101, the last #67 leftover).
my $config = App::karr::Config->from_merged( $self->store->effective_config );
$task->update_timestamps( $old_status, $self->move,
( $config->statuses )[0], $config );
}
return () unless $self->save_task($task, $oid);
return $task;
});
}
1;
__END__
=pod
=encoding UTF-8
=head1 NAME
App::karr::Cmd::Pick - Atomically find and claim the next available task
=head1 VERSION
version 0.500
=head1 SYNOPSIS
karr pick --claim agent-fox
karr pick --claim agent-fox --status todo --move in-progress
karr pick --claim agent-fox --tags backend,urgent --json
=head1 DESCRIPTION
Selects the next available task for an agent, taking class of service,
priority, blocked state, and claim expiry into account. When the board lives in
a Git repository, the command also uses lock refs so concurrent agents do not
pile onto the same candidate.
=head1 SELECTION RULES
=over 4
=item * Eligible statuses
If C<--status> is omitted, tasks in the board's terminal statuses are excluded
-- its final configured status and C<archived>, which on the default board
means C<done> and C<archived>.
=item * Claim timeout
Already claimed tasks are ignored unless their claim timestamp has expired
according to C<claim_timeout>. A C<claimed_by> of the empty string is not a
claim; it is how kanban-md spells "unclaimed".
=item * Ordering
Candidates are sorted by class of service, then by priority, then by task id.
The class and priority lists come from the board's own configuration
(C<priorities> and C<classes> in C<config.yml>), not from a hardcoded table --
so a board imported from kanban-md with a longer priorities list ranks
according to its own list. Lower class index is more urgent; higher priority
index is more urgent (matches kanban-md's pick.go).
=item * C<--move>
Optionally updates the picked task to a new status such as C<in-progress>.
=back
=head1 JSON OUTPUT
With C<--json> a successful pick prints the picked task as a JSON object, and
picking nothing prints C<< {"picked":null} >>. Either way the exit status is
C<0>, so a polling agent decodes the payload and tests for a task rather than
reading the exit code or the message text.
=head1 EXCLUSIVITY
The board is read once to rank candidates, but nothing is decided on that
reading. Every candidate is re-read from its ref after its lock is taken, tested
against the same predicate a second time, and written back under a
compare-and-swap on the OID it was just read from. An agent that loses that
swap has picked nothing and moves to the next candidate.
That belt-and-braces shape is not defensive programming, it is the fix for #86.
The lock ref alone cannot make a pick exclusive: its holder identity is the
clone's C<user.email>, which every agent on one machine shares, so twelve
parallel picks each acquired the lock quite legitimately, each acted on a
snapshot taken before any lock existed, and each wrote its claim over the
previous one -- nine agents were told they owned task 1, and the card named only
the last of them. The lock now only keeps agents off each other's candidates;
the compare-and-swap is what binds the claim.
=head1 LOCK EXPIRY
( run in 1.163 second using v1.01-cache-2.11-cpan-788537b7465 )