NAME
App::karr::Role::TaskMutation - The one guarded path for changing an existing task
VERSION
version 0.600
DESCRIPTION
Commands that change a task that already exists -- move, edit, delete, archive, handoff -- share three things through this role: the compare-and-swap loop that persists the change, the single implementation of "this task's status becomes that", and the batch loop the id-list commands run that pair over.
Claim ownership is checked by the caller, inside the callback it hands to update_task_guarded, rather than by update_task_guarded itself, because edit --release deliberately acts on somebody else's claim. Putting the check in the callback is what keeps it under the same guard as the write: a check made before the loop is a check made against a revision that may no longer be there (tickets #44, #46, #56).
SEE ALSO
karr, App::karr, App::karr::Role::ClaimTimeout, App::karr::Cmd::Move, App::karr::Cmd::Edit, App::karr::Cmd::Delete, App::karr::Cmd::Archive, App::karr::Cmd::Handoff
run_batch
Runs one callback per id and keeps going when an id fails, so that a bad id in the middle of the list cannot skip the ids after it. Returns the collected per-id results and the number of failures.
my ( $results, $failed ) = $self->run_batch( \@ids, sub {
my ($id) = @_;
...
return { id => $id, title => $title };
} );
Whatever the callback returns is appended to the results; a callback that dies contributes { id => $id, error => $message } instead and the message is also warned to STDERR unless --json is in force. The STDERR text carries any suggestion line the failure came with ("command_hint" in App::karr::Error); the error field stays the single line it has always been. Usage errors are re-thrown rather than collected: they condemn the whole invocation, not one id.
report_batch_failure
$self->report_batch_failure( $failed, scalar @ids );
Ends a batch that had failures with exit code 1 and a one-line summary, after the ids that did succeed have been committed. A no-op when nothing failed.
no_change
return $self->no_change if $task->status eq $wanted;
The value a "update_task_guarded" callback returns to say that this revision of the task needs no write: the compare-and-swap write, the updated bump that comes with it and the activity-log entry are all skipped, and the task is returned unwritten. Any other return value -- including none -- writes as before, so a callback that does not know about this method is unaffected.
Deciding it inside the callback rather than on a read taken beforehand is the point (tickets #44, #46, #56): "nothing to change" is a statement about a revision, and the revision it is made about is the one that would have been written.
task_not_found
die $self->task_not_found($id);
The one message every command on the mutation path raises when an id names no card: the id as the caller gave it, and karr list --compact on its own last line as the way to see the ids that do exist. Shared so that move, edit, delete, archive and handoff -- which reach it through "update_task_guarded", "delete_task_guarded" and the unguarded pre-reads in App::karr::Cmd::Archive and App::karr::Cmd::Delete -- spell it one way (ticket k264, the shape App::karr::Cmd::Needs got in k263).
update_task_guarded
Reads the task, runs the callback against it, and writes it back only if the task ref is still exactly where it was when it was read. If another agent got in first the callback's work is discarded and the callback is re-run against the fresh task, so the decision it makes and the bytes that land are always the same revision. Returns the written task.
my $task = $self->update_task_guarded( $id, sub {
my ($task) = @_;
$self->check_claim( $task, $self->claim );
$task->title('New title');
} );
The callback runs once per attempt, so it must be a function of the task it is handed -- read $task->status, never a status captured beforehand -- and anything it does besides changing that task has to be safe to do twice. A side effect outside the task object is allowed where a repeat replaces it instead of adding to it: "apply_status_change" calls "check_dependencies" in App::karr::Role::DependencyCheck, which records into a slot keyed by task id and clears that slot on entry, so what a losing attempt wrote is overwritten by the attempt that wins rather than added to. Appending to a list, incrementing a counter or printing would each have come out once per attempt -- printing is why the dependency warnings are emitted by "dependency_report" in App::karr::Role::DependencyCheck once the write has landed, and never from inside the callback.
delete_task_guarded
Deletes a task, but only if the task ref is still exactly where it was when the claim rule was applied to it. If another agent got in first the check is re-run against the fresh task -- so a claim that lands in the window blocks the delete instead of being deleted with the card -- and a task another agent deleted meanwhile is reported as not found. Returns the deleted task.
$self->delete_task_guarded( $id, undef );
apply_status_change
The only place a task's status is assigned. Rejects a status the board does not configure, releases the claim on a reopen, applies require_claim and the lifecycle stamps, records any unsatisfied dependencies ("check_dependencies" in App::karr::Role::DependencyCheck -- recorded here, emitted by the caller once the write has landed), and returns the status the task had before the change.
my $old_status = $self->apply_status_change( $task, 'in-progress', $claimant );
A change to the status the card already carries changes nothing: the status name is still checked, and then this returns that same status having touched neither the card nor its claim -- so require_claim, the dependency check and the lifecycle stamps are all skipped along with it (ticket #231, the shape kanban-md's Move uses). Callers that write only because of the status change -- App::karr::Cmd::Move -- recognise it and skip the write, so updated is not bumped and no activity-log entry is appended for an event that did not happen. A caller that changes something else in the same breath, edit --title or move --claim, still writes what it changed.
Reopening releases the claim. A card leaving one of the board's terminal statuses ("is_terminal_status" in App::karr::Config) for a non-terminal one, with no $claimant passed in, has claimed_by and claimed_at cleared: a claim is the lease an agent holds while working a card, and the name kept on a finished one is provenance, not a lease to carry back into a working column. Left there, it blocked every other agent and made karr pick skip the card in silence (ticket #224).
Three things it deliberately does not do. done -> archived is terminal to terminal and keeps the name. A caller that brings a claimant -- move ID todo --claim NAME, and every handoff, where --claim is required -- hands the card to that agent instead. And "terminal" is whatever this board's config calls terminal, never the literal done.
The release happens before the require_claim check, so a claim on its way off the card cannot satisfy it: reopening straight into a column the board says needs an owner asks for --claim rather than handing that column to whoever had finished the work (the shape of ticket #150).
claim_hint_tokens
my @tokens = $self->claim_hint_tokens( $task, 'in-progress' );
The words after karr in the suggestion apply_status_change prints when a status needs a claim and none is on the card. A hook, because the message is raised here and only the consumer knows which command the caller actually typed.
The default is karr edit ID --status STATUS --claim NAME, which is right for every consumer that reaches a require_claim column through an option rather than a positional -- App::karr::Cmd::Edit, and App::karr::Cmd::Archive, which has no --claim of its own to offer. App::karr::Cmd::Move overrides it with its own spelling. App::karr::Cmd::Handoff never gets here: --claim is required on that command, so the check above is always satisfied.
SUPPORT
Issues
Please report bugs and feature requests on GitHub at https://github.com/Getty/karr/issues.
IRC
Join #langertha on irc.perl.org or message Getty directly.
CONTRIBUTING
Contributions are welcome! Please fork the repository and submit a pull request.
AUTHOR
Torsten Raudssus <getty@cpan.org>
COPYRIGHT AND LICENSE
This software is Copyright (c) 2026 by Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/.
This is free software, licensed under:
The Artistic License 2.0 (GPL Compatible)