NAME

App::karr::Foundation::Executor - karr-foundation chain executor -- picks a ready step, runs it, writes its state back

VERSION

version 0.600

SYNOPSIS

karr-foundation chain              # execute what is ready
karr-foundation chain --dry-run    # say what would run, touch nothing

DESCRIPTION

The VM. App::karr::Foundation::ChainStore holds the program -- the planned steps, their edges, their prechecks and the log of the runs that worked through them -- and this is what executes it: it takes a step the chain says is ready, measures the facts its precheck asks about, runs it, and writes back what happened.

A layer above the repo modes, not a fourth one beside them

drain, single and ticket are per-repository settings, and the chain is fleet-wide. A mode: chain in a .karr file could not answer the only question that matters here -- which step of the DAG is next -- because that answer lives in the hub and is about every repository at once. So the executor is the caller of those modes rather than their sibling:

pull refs/karr-foundation/*
ready_steps()
  kind: ticket   -> the existing ticket-mode path in the target repo
  kind: shell    -> the command, in the target repo, under its own lock
  kind: question -> the mailbox in the hub, resolved under its own policy
update_step (CAS) + log_run
push refs/karr-foundation/*

A kind: ticket step therefore inherits the board lock, the claim discipline, the #158 ownership guard and the run's own report from ticket mode ("_drain_repo" in App::karr::Foundation) instead of carrying a second copy of them, and ticket mode stays a unit that can be tested on its own.

Pull before reading, push before working

The compare-and-swap on a step ("update_step" in App::karr::Foundation::ChainStore) is the second line of defence, not the first. Two machines that never exchange refs would each read pending out of their own clone and each win their own local CAS, so the ordering is what actually keeps one step to one machine:

  • pull first, and refuse the tick when the pull fails. Everywhere else in karr-foundation a failed fetch is a warning, because the fallback is this machine's own view and that is the safe direction. Here the fallback is running a step somebody else is already running, so the tick stops instead.

  • push the claim before the work starts. The window that matters is the length of the step, not the length of the write: a claim published after a half-hour agent run would have left the step readable as pending for that half hour. A claim that cannot be published is rolled back to pending locally -- no other machine ever saw it -- and the step is left for the next tick.

  • push the result, and the run log with it. That push is best-effort: the work has already happened, the state is written locally, and the next tick publishes it. Refusing to record a run that is over would be the worse answer.

Who measures the facts

ChainStore reads the precheck grammar and evaluates it, and deliberately measures nothing: measuring a fact means reading a board, and reading a board is execution. This class is where execution lives, so this is where the facts come from ("facts_for"). The vocabulary is small, and everything about a board comes off one board read:

board_actionable    yes | no     any task an agent could still pick
ticket_status       the status of the step's own ticket
ticket_blocked      yes | no
ticket_claimed      the claim name on it, or the empty string
ticket_links        settled | open | missing
question_state      answered | open | overdue

question_state is the one that is not measured off a board at all: it comes from the question mailbox in the hub, and only for a kind: question step, because that is the only kind that has a question. Measuring it for every step would buy a fact no other kind's precheck could be about and pay a mailbox read per step for it.

ticket_links is measured off another board -- the one the card's cross-board links name ("A cross-board link is a fact about another board"). It comes off the same card as the other three ticket_* facts and on the same condition, which is that the step names a card that is on the board, not that its kind is ticket: a kind: shell step naming the card its build belongs to gets the same four facts, and gating one of them on the kind would mean two rules for one card read. What it costs is bounded by the card and not by the kind -- a card with no needs: tag reaches no other repository at all, which is why the argument that keeps question_state behind its kind does not apply to it.

A fact that cannot be measured -- a repository that is not a board, a ticket that is not on it, a question step nothing in the mailbox names -- is absent, and an absent fact makes a precheck not hold whichever operator it uses ("precheck_holds" in App::karr::Foundation::ChainStore). That is the direction that costs a planning round rather than whatever the step would have done.

A card can wait on a card in a different repository: needs:BOARD#ID, the link App::karr::CrossBoard puts on it (#192). ticket_links is what the chain can ask about that, in one word for the whole card:

  • settled -- every link the card carries is in one of the far board's own terminal statuses, never a hardcoded done (#67). A card carrying no link is settled too: nothing elsewhere is holding it, exactly as ticket_blocked says no for a card nobody blocked. That is also what keeps a precheck working after karr needs --resolve has done its job and dropped the tag -- the reading where an empty card had no answer would make the successful resolution of the link the thing that stops the step for ever.

  • open -- a far card exists and is not finished.

  • missing -- a link names a card the far board does not have. Not settled: unblocking on the strength of a ticket nobody can find is the silent wrong answer, which karr already declines for depends_on (#123) and declines here too (#192, decision 5). Where the card carries several links the value is the first unsettled one in tag order, and settled only when every one of them is -- the same order in which they release the card, and the same rule question_state follows.

The fact is absent as soon as one link names a board this machine cannot place: an unknown name, or a directory holding no board. #192 treats that as an answer rather than an error and so does this -- but the answer is "not measurable here", so the precheck does not hold, the step goes stale and the planner hears about it. A machine holding four repositories of a six-repository fleet must not run a step on the strength of a board it never read, and the other machine, the one that does have it, measures the fact and runs the step.

Where the directories come from is not a decision this class takes twice: the fleet config this run already read (dirs: outright, scan: as children, matched on the directory basename) is handed to "config_data" in App::karr::CrossBoard as it stands, so --config relocates it here as well and a second local file describing the same fleet never appears -- the argument #189 used for resolving the hub exactly once.

What this does not do is resolve anything. The far board is read exactly as it stands in that working copy -- nothing is fetched, because pulling somebody else's repository from inside a tick is transport nobody asked for -- and the blocked flag on the near card is left alone even when every link has settled. The link is the fact, blocked is the decision (#192, decision 4): somebody set it on purpose, karr needs --resolve is what lifts it, and an executor that lifted it unasked would be stricter than the board it coordinates -- the line Picker holds by not filtering (#185) and pick by handing the card over with a warning (#123). verified, the back-reference half of the link, is not part of the fact either: --resolve settles on settled alone, and a second opinion here would mean a far card whose author forgot the escalated-from: tag could never settle anything.

What a failure does to the DAG

Nothing, and that is the design rather than an omission. "ready_steps" in App::karr::Foundation::ChainStore releases a step only when every step it needs is done, so a step that ends failed or stale stops its own branch by construction: its dependents never become ready, no cascade has to be computed, and every branch that does not run through it carries on. The chain then cannot finish, and that unreachability is exactly the signal on_stall: plan names.

The planner itself does not run in here. Where the spec says "call the planner" this executor records that the planner is wanted -- a planner entry in the run log naming the step and why, and a line of output at the end of the tick -- and writes nothing about the plan that a planner would have to undo. What has changed with #210 is only who hears it: the recorded entries are handed to App::karr::Foundation::Coordinator when the tick is over, and where the fleet marks an agent role: coordinator karr-foundation calls it once for all of them. Where it marks none, this is exactly what it was -- a line of output for the operator, who is then the planner.

Three outcomes are deliberately not failures, because none of them is a statement about the plan:

  • A common error (a rate-limited or broken agent command) requeues the step to pending. The board's own cooldown and the agent's availability record have already been written by the drain; the step is simply not this machine's to run right now.

  • A skipped board -- disabled, locked by another tick, in cooldown, or on an agent that is currently failing -- requeues the step the same way and says which of those it was.

  • A step naming a repository this machine does not have is left untouched and unclaimed. The chain is shared and the machines are not, so this is the ordinary case in a fleet, not a broken plan.

A question step resolves a question, it does not ask one

A kind: question step waits on the mailbox (App::karr::Foundation::Questions): the planner asks the question first, with karr-foundation ask --step ID, and the step does nothing but resolve it. A step does not ask its own question, and that is a decision about schemas rather than about convenience -- a self-asking step would have to carry the question text, its options, its policy, its default and its deadline in the step itself, which is the mailbox's schema written out a second time and kept in step with the first one by hand.

The consequence is that a ready question step nothing in the mailbox names is a planning error, and it is reported as one: stale, with the reason in the run log and on the tick's output, rather than left waiting quietly for a question that is never going to arrive. Same answer, same cost and same reason as a kind: ticket step whose card is not on the board.

What a step that does have its question then does is "resolve" in App::karr::Foundation::Questions plus the policy the asker wrote down for the case where nobody answers:

answered                  done, and the answer is in the run log
open                      pending and unclaimed; its dependents wait
overdue + block           pending: waiting IS what block means
overdue + use_default     done, with the default as the answer
overdue + escalate_to_ai  pending, and the planner recorded as wanted

Waiting never holds the tick up: a step that waits is considered once, said out loud and left, every other branch of the chain runs, and the dependents of the question wait by construction because ready_steps releases a step only when everything it needs is done.

escalate_to_ai is recorded here and answered outside, which is the same shape a kind: plan step has: the step is left alone, a line says so, and the recorded want reaches the coordination agent at the end of the tick (#210). The step is not resolved on the agent's behalf -- whatever it decides arrives as an answer in the mailbox or as a new chain, through the same doors a person uses, and until then the question is still open and the step still waits.

A step waits until every question naming it is settled. More than one question on one step is not what a planner normally writes; what decides it is that the alternative -- the first answer releasing a step somebody has asked a second question about -- would drop an unanswered question on the floor, which is the one thing this mailbox refuses to do anywhere else. A question whose step is not ready yet is simply not looked at, and that is the good case: it can be answered long before the step arrives, and then the step never waits at all.

What a question names is a step id and nothing else, so a planner that re-uses an id in a later chain inherits whatever the earlier chain left unanswered under it. It is not fixed by scoping the question to a chain, because a question asked before the chain that waits on it is the good case above and no timestamp can tell the two apart; it is fixed by answering or deleting a question the fleet has stopped caring about, which is what karr-foundation answer and "delete_question" in App::karr::Foundation::Questions are for.

What this executor does not do yet

kind: plan steps are recognised and left pending, with the planner recorded as wanted and a line saying so. That is not a hole: a plan step is a request for a new plan, and this class executes plans rather than making them. The request reaches the coordination agent at the end of the tick (#210), which writes the next chain -- and the next tick executes it. It hangs off the dispatch on kind at the top of one step, which is why it is one place rather than a thread through this class.

Cross-board links are measured and not resolved, and that is a decision rather than the seam it used to be: ticket_links tells a precheck what the far cards are doing, and lifting the block stays with the person or the command that took it on ("A cross-board link is a fact about another board").

Steps are executed one at a time within a tick. Concurrency in the chain is across machines -- which is what the pull/claim/push ordering above buys -- and the machine-local concurrency of several boards at once stays where it already is (App::karr::Foundation::Limits and the concurrent runner).

SEE ALSO

App::karr::Foundation, App::karr::Foundation::ChainStore, App::karr::Foundation::Questions

foundation

The owning App::karr::Foundation, held weakly. Required.

store

The hub's App::karr::Foundation::ChainStore. Built from the foundation's hub: setting, and a user error when there is none: the chain is fleet state, and executing a plan nobody else can see is not a smaller version of executing the fleet's plan -- the same argument the question mailbox makes.

run

my $exit = $executor->run;

One tick of the VM: pull the fleet namespace, take every step the chain says is ready, and work through them. Returns a process exit code -- 1 only when the tick refused to start (the fleet namespace could not be pulled), 0 otherwise, including when steps failed. A failed step is a statement about the plan, not about karr-foundation.

Steps that become ready because of this tick's own work are picked up in a further round, each round preceded by a fresh pull, so a linear chain does not need one cron tick per step. A step is considered at most once per tick, which is what bounds the rounds and what keeps a requeued step from spinning.

With --dry-run nothing is pulled, claimed, executed or written: the ready set is listed with the verdict each precheck currently gives.

facts_for

my $facts = $executor->facts_for($step);
# { board_actionable => 'yes', ticket_status => 'todo',
#   ticket_blocked => 'no', ticket_claimed => '',
#   ticket_links => 'settled' }
# { question_state => 'open' }                     # a kind: question step

Measures the facts a step's precheck may ask about, off the board the step names -- which is why it lives here and not in the store: reading a board is execution. See "Who measures the facts" for the vocabulary and for why an unmeasurable fact is left out rather than defaulted.

ticket_claimed is the claim name as the card carries it, not whether that claim is still live: whether a claim has expired is karr pick's question (App::karr::Role::PickRules) and answering it differently here would be a second opinion about the same card.

question_state is measured only for a kind: question step, off the mailbox rather than off a board, and it is the state "resolve" in App::karr::Foundation::Questions gives -- answered, open or overdue -- not this class's conclusion about it: an overdue question with a use_default policy settles the step and still reports overdue, because the fact is about the question and the policy is a separate thing the plan can read. Where a step has more than one question the fact reports the lowest-id one nobody has answered, and answered only when every one of them has been, which is the same order in which they release the step.

ticket_links is the state of the card's cross-board links, and the only fact here measured off a repository the step does not name. It is a report and never a repair: nothing is fetched, nothing on either board is written, and a settled link does not lift the blocked flag on the near card. See "A cross-board link is a fact about another board" for what each value means, for why a card with no links is settled, and for why a link this machine cannot place takes the fact away rather than defaulting it.

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)