Dependency-aware agent operations¶
Prompt¶
Before responding to questions or discussion points in this document,
explore the shakenfist codebase thoroughly. Read the cluster operation
dependency machinery (depends_on / runs_after on
BaseClusterOperation in shakenfist/operations/baseoperation.py,
the dependency schema model in
shakenfist/schema/operations/baseclusteroperation.py, and the
dequeue-time evaluation, of which there are two near-identical
copies, in shakenfist/daemons/queues/workitem.py and
shakenfist/daemons/network/workitem.py),
the agent operation object and its queue
(shakenfist/operations/agentoperation.py,
Instance.agent_operation_next in shakenfist/instance.py), the
sidechannel dispatch loop
(shakenfist/daemons/sidechannel/main.py), and the deadlines plan
this plan is gated on
(docs/plans/PLAN-agent-operation-deadlines.md), whose design this
plan assumes has landed. The client-side surface lives in the sibling
client-python repository, and the first example application in the
sibling client-python-k3s repository. Ground your answers in what
the code actually does rather than guessing.
All planning documents go into docs/plans/.
This plan began as a placeholder: intent, the decisions already
made in discussion, and the known open questions, intentionally light
on implementation detail, with phase 0 to resolve those questions
into a decisions section and re-cut the phase table. Phase 0 has done
both -- see "Decisions from phase 0" below, which replaces the open
questions, and the re-cut table at the end -- so the sections above
it are the record of how the plan was conceived and the sections
below it are what will be built. Where the two disagree, phase 0's
decisions win and say so. It could not begin until
PLAN-agent-operation-deadlines had landed, because it builds
directly on that plan's expiry semantics, terminal-only queue pop,
and per-command capability registry. That plan is now complete (its
nine phases all merged, the last of them 9bca46412), so the gate
is clear and phase 0 is in progress.
When we get to detailed planning, I prefer a separate plan file per
detailed phase, named for the master plan with -phase-NN-descriptive
appended before the .md extension.
I prefer one commit per logical change, and at minimum one commit per phase. Do not batch unrelated changes into a single commit.
Situation¶
Agent operations execute linearly per instance with independent
outcomes — ordering, not dependency; shell ;, not && (see the
failure semantics section of PLAN-agent-operation-deadlines.md).
Callers who need dependency semantics today either submit-and-await
each operation serially, or rely on the intra-operation command list
(which aborts remaining commands on error but is not composable from
the public API).
Meanwhile cluster operations already carry a full dependency
vocabulary, evaluated at dequeue in daemons/queues/workitem.py:
depends_on— fate-sharing. A missing dependency errors the dependent operation; a dependency inERROR/DELETED/ABORTaborts it (the cascade); an in-flight dependency defers it, but not forever — once the defer budget is spent (DEPENDENCY_DEFER_BUDGET, 30 defers of exponential backoff and so roughly six minutes)abandon_dependency_wait()aborts the dependent operation, because a dependency which never goes terminal must not park one indefinitely (issue #3992).runs_after— ordering only. Wait for the named operation to finish; its outcome is irrelevant; a missing operation is a logged warning, not fatal.
That is exactly the dependency-versus-ordering distinction agent
operations lack, with the cascade following declared edges rather
than queue adjacency — which is what makes it correct in a queue
shared by unrelated callers. A fate-shared chain of depends_on
edges is a transaction in every sense that is meaningful against a
running operating system (rollback is not).
Separately: agent operations are a fairly unique idea and subtle to
use well — this has become clear from how much design discussion the
deadlines plan required. The documentation needs to be genuinely
good, with worked examples, and the client-python-k3s plugin (which
orchestrates k3s clusters over the sf-agent2 side channel, storing
cluster state in namespace metadata) is already a de-facto example
application. It should become the first of a curated suite of
example applications that adopt each new agent operation feature as
it lands, serving as both living documentation and functional
exercise of the feature set.
Decisions already made¶
Settled in discussion (2026-08-14, the same thread that produced the deadlines plan) and not open questions:
- Extend
depends_on/runs_afterto agent operations rather than introducing a lane or session identifier or a new operation type. Dependencies are declared per-operation on the existing verbs; operations that declare nothing keep today's contract unchanged (linear order, independent outcomes). The cascade follows declared edges only. - Cross-instance edges are in scope. Agent operation state lives
in MariaDB and is cluster-visible, so an operation on instance A
depending on an operation on instance B is a state read at
dispatch evaluation, the same read
workitem.pyperforms for cluster operations. This enables fire-and-await orchestration: a rolling update submitted as one dependency chain across a fleet, awaited as a unit by awaiting its final operation. - Settle delays are an attribute on the dependency edge — "satisfied N seconds after the dependency completed" — not a sleep operation, which would hold an instance's executor slot to do nothing and require a healthy agent to run a no-op.
- Deadlines are the cycle-breaker. Amended by phase 0: there
are no cycles to break. As written, this said that a
dependency-blocked operation accrues queue time against its
deadline (from
PLAN-agent-operation-deadlines) so that user-created dependency cycles resolve by expiry rather than deadlocking. The premise is false. Adepends_onedge can only name an operation which already exists: the API assigns every agent operation's UUID, a client cannot pre-declare one, an edge is static at creation and there is no API to add one later. Every edge therefore points backwards in creation order and the graph is a DAG by construction, which is also why decision 6 below does no graph walk.
The first clause still holds and is still useful — queue time does
accrue against the deadline, so a blocked operation is not
immortal — but it is not what makes this plan safe, and it is not
a sufficient bound either, because a caller may ask for a deadline
of a day (see decision 2 below). What the plan actually needs from
PLAN-agent-operation-deadlines, and why that plan remains a hard
prerequisite, is its queue-time expiry at dequeue, its
terminal-only lazy queue pop, its node-local reaper, and the
capability-token precedent which lets a new terminal state be
introduced without hanging a released client's await.
5. Documentation with worked examples is a first-class
deliverable, not an afterthought phase: a developer-guide
treatment of the agent operation model (ordering, independence,
deadlines, dependencies, retry) plus a curated example-application
suite starting with client-python-k3s, updated as features land.
Note that this is no longer greenfield:
PLAN-agent-operation-deadlines phase 7 wrote
docs/operator_guide/agent_operations.md (one executor per
instance, the two budgets and where they are enforced, expired
versus error, retry and the reaper, what to tune), and the API
surface is in docs/developer_guide/api_reference/. What does not
exist is a developer-guide page for the model itself.
Design sketch¶
Mechanically, the extension is: add depends_on / runs_after (and
per-edge settle seconds) to AgentOperation static values and the
creating API endpoints; admit ObjectType.AGENTOPERATION as a
permitted dependency target; and evaluate edges at dispatch time in
the sidechannel dispatcher, before an operation is handed to an
executor.
Two notes on that sketch, from phase 0's survey of the tree. The
permitted-types set is VALID_CLUSTER_OPERATIONS in the dependency
schema, whose only consumer is the dependency model's own
validator — so widening it is mechanically cheap, but it admits the
edge in both directions and so answers open question 2 by accident,
in favour of mixed-type edges, with no evaluator on the cluster side
which can read agent operation state. The API parameter has a worked
precedent rather than being novel: an array of structured objects is
declared the way arrayofdiskspec and arrayofnetworkspec are
(shakenfist/external_api/base.py:821-829), each nesting a schema
which shakenfist/external_api/validation.py:626 compiles into a
list of nested fields. The evaluation semantics mirror
workitem.py: missing dependency errors, failed dependency cascades
(abort/skip), in-flight dependency waits. The sidechannel has no
defer machinery — a blocked operation simply stays queued and is
re-evaluated on later dispatch passes, with the deadline bounding how
long that can continue.
The awaited-transaction experience is the client's: submit the chain, await the final operation (which transitively awaits the rest), and report per-operation outcomes from the chain on failure.
Decisions from phase 0¶
Phase 0 resolved the seven open questions this section used to hold. Each decision below says which question it answers, and the reasoning is kept because the next reader of this plan is the person implementing it. The measurement and the consumer audit behind them are in the phase 0 plan.
- Strict FIFO is preserved: a dependency-blocked operation keeps its place in its instance's queue and blocks the operations behind it. Declaring a dependency does not opt an operation out of queue ordering. (Open question 1.)
Instance.agent_operation_next() is built on the head of the
queue being the unit of recovery, and its crash-safety argument is
that an instance has one executor; returning a dispatchable
non-head candidate replaces that head pointer with per-operation
claims, which PLAN-agent-operation-deadlines.md named as the
reason concurrency is its own master plan. The alternative is also
not merely "better utilisation": it gives the instance queue two
contracts, so "put a file, then execute it" would hold only for
operations which declared nothing.
The cost is that one caller's blocked chain stalls operations
another caller submitted against the same instance.
Whether that happens in practice could not be measured, and the
plan records it as an assumption rather than a finding. Over the
22.5 days of sfcbr history Loki retains, half of all agent
operations (913 of 1,804) arrived at an instance which already had
an unfinished operation and the deepest queue reached eleven, so
deep queues are real. But nothing records who submitted an
operation: a request id is unique per operation, the API request
log's identity field is null, and an operation's namespace is
copied from its instance rather than from the caller, so "no
cross-namespace overlap" is true by construction. Every deep queue
observed was a burst of different commands on one instance, which
is what a single client fanning out looks like and equally what
several cooperating clients would look like. Treating "effectively
one caller at a time per instance" as an operating assumption is
therefore a choice, taken deliberately, and a reader who needs it
to be a fact needs a submitter field on the operation first.
- The stall decision 1 accepts is bounded by a dedicated
dependency-wait budget, defaulting to 300 seconds, and not by the
caller's deadline. A blocked head whose wait exceeds the budget
is aborted, in the manner of
abandon_dependency_wait(). The budget is wall-clock from the first blocked evaluation, recorded as an operation attribute on first block, rather than a count of dispatcher passes: the dispatcher's pass rate is throttled and variable where the cluster dispatchers' backoff is not, and one write beats one write per pass. It is a configuration option.
The deadline cannot be that bound.
AGENT_OPERATION_DEFAULT_DEADLINE is 600 seconds but a caller may
ask for AGENT_OPERATION_MAX_DEADLINE, 86400, so with deadlines
as the only bound one caller could stall an instance's queue for a
day by asking for a day.
300 seconds is chosen against measurement rather than by analogy.
A dependency wait legitimately contains the dependency's own queue
wait and its execution, which on sfcbr run to 39 seconds at p95
and 142 seconds at worst for a single hop, so the budget is about
twice the worst single-hop latency that cluster has produced. A
chain more than two hops deep on a busy instance could legitimately
exceed it; that is documented rather than designed around, because
the budget aborts an operation and does not corrupt anything.
- A failed
depends_onmoves the dependent operation toabort, a terminal stateAgentOperationdoes not reach today. Not toerror, which would claim the operation failed when its dependency did, and not toexpired, which would claim a timing budget ran out.abortis the vocabulary the cluster-operation cascade already uses, and it is already inconstants.FINAL_OBJECT_STATES, so an aborted operation is hard-delete swept where an errored one is not (issue #3922).
Introducing a terminal state is only safe because the dependency
parameters are gated behind an API_CAPABILITIES token, the way
deadline_seconds was: an operation can only reach abort if a
client which declared a dependency created it, and such a client
knows the state. That argument covers a client which polls the
operations it created and not one which polls an instance's
whole operation list, which phase 0's audit found
client-python-k3s does. Its await_idle() treats an
unrecognised state as still running by design and has no timeout,
so one aborted operation would stall a released plugin on that
instance until the hard-delete sweep cleared it an hour later.
Teaching client-python-k3s the state, in its own pull request
and release, is therefore a prerequisite of phase 1 rather than
part of phase 5's adoption work.
Two obligations come with the state, both single-commit: abort
must enter TERMINAL_STATES in the same commit that gives
state_targets an edge to it, or agent_operation_next() takes
its unknown-state branch and wedges the instance's queue
permanently; and state_targets needs abort -> deleted, because
delete() removes an operation's rows before assigning the state.
- Mixed-type edges between agent and cluster operations are out of scope, and the mechanism in decision 5 keeps them out by construction. (Open question 2.)
The use case is real — "run this script after the artifact fetch
completes" — and is expressible today by awaiting the fetch
client-side. Admitting it would mean evaluating edges in the two
cluster-operation dispatchers as well, whose cascade helpers reach
for defer(), current_defer_count and a schema model's
target_fields, none of which an agent operation has. Recorded
under Future work and filed as an issue.
-
An agent operation's edges use their own model and their own permitted-target set, not the shared
dependencymodel. Phase 0 found that wideningVALID_CLUSTER_OPERATIONS, as the design sketch proposed, would admit the edge in both directions and so settle open question 2 by accident. InsteadVALID_DEPENDENCY_OPERATIONSadmitsObjectType.AGENTOPERATIONfor the agent edge model alone, the cluster validator keeps the set it has, and a cluster operation still cannot name an agent operation. The per-edge settle delay (decision 3 of the section above) is a field on the agent edge model for the same reason: on the shared model it would appear on every cluster-operation edge with nothing honouring it. -
Edges are validated at submission, and that is also the answer to cycles. (Open questions 3 and 5.) The API handler reads each named dependency once and refuses with a 400 if it does not exist, is not an agent operation, or belongs to a namespace the caller could not read. It does not walk the graph, because there is no cycle to find: see the amended decision 4 above. Namespace is checked at submission and never re-checked at evaluation — the edge was authorised when it was declared, and re-checking would let a later namespace change break a chain already in flight.
-
Why an operation was cascade-aborted is on the wire. (Open question 4.)
external_view()carries the failed dependency's type, UUID and state, in the manner ofexpiry_reason. The cause cannot ride onerror_message, because theerrorsetter refuses any state not ending inerror, so without a field of its own the reason would live only on the state row and an instance audit event, neither of which a client sees. Issue #4075 is the precedent: a reason not on the wire is a defect, so this one is on the wire from the start. -
Edges are evaluated in the sidechannel dispatcher, between
agent_operation_next()andstart_instance_executor(). (Open question 6.) Not insideagent_operation_next(), which is the tempting precedent because the deadline check lives there, but which holds theagent_operationsattribute lock across every read it makes. In the dispatcher a blocked operation simply stays queued, which is what the sidechannel's lack of defer machinery makes free, and a cascade abort is a state write the next pass's lazy pop retires. Evaluating afteragent_operation_next()also means an expired head is expired rather than cascaded.
Cost control, because this loop's per-instance read cost is
budgeted: an operation which declares no edges reads nothing; the
reads hang off the existing per-instance throttle
(DISPATCH_CHECK_INTERVAL, five seconds) rather than adding a
cadence; a dependency observed in a terminal state is cached in
process for the life of the dispatcher generation, since terminal
is absorbing; and the (GetInstanceAttributes, sidechannel) entry
in shakenfist/data/database_load_budget.yaml is amended in the
same phase as the code, as a code-derived term. The dispatcher's
supervisor replaces a thread which has not completed a pass in 120
seconds, which is the ceiling on how much work a pass may grow by.
One consequence to document rather than fix: the cascade is lazy,
so a dependent which has not reached the head of its queue reads
as queued through the API even after its dependency has failed.
-
An evaluator reads a dependency's state by UUID and never hydrates the object.
AgentOperation.delete()removes an operation's static and attribute rows before assigningdeleted, sofrom_db()returns nothing for a user-deleted dependency and an evaluator which hydrated would misclassify it as missing. There is a related ambiguity with no clean answer, which the documentation must state: a dependency which completed, aborted, expired or was deleted is swept an hour after it finished, while an errored one never is, so a dependent which reaches the head long afterwards finds the dependency absent and cannot tell success from failure. -
Cross-instance edges cost nothing extra at evaluation and land with same-instance edges. The evaluator reads a dependency's state by UUID and never looks at which instance owns it; the only instance-aware work is decision 6's namespace check in the API handler. The phase which was to do them separately is folded into the evaluation phase below.
-
The documentation deliverable is a developer-guide page plus an example index. (Open question 7.) The model treatment — ordering, independence, deadlines, dependencies, retry, with worked examples — goes to a new
docs/developer_guide/agent_operations.md, becausedocs/operator_guide/agent_operations.mdis a tuning and enforcement page for operators while the dependency vocabulary is an API contract. The example suite is an index section naming sibling repositories, withclient-python-k3sfirst and its rolling update as the worked example. No new CI exercises the examples: each example repository has its own. The second example is chosen when there is a second feature to exercise, and is filed as an issue rather than invented here.
Non-goals¶
- Rollback. Compensating actions against a running operating system are the caller's domain; the transaction concept here is ordering plus fate-sharing, nothing more.
- Concurrent execution of independent agent operations on one instance. Dependency edges express ordering constraints; they do not change the one-executor-per-instance execution model. If parallel lanes are ever wanted, that is the separate re-engineering recorded in the deadlines plan's non-goals.
- Agent-side cancellation of in-flight work (tracked from the deadlines plan; would sharpen cascade behaviour but is not required by it).
Phases¶
Re-cut by phase 0. Two changes from the table this replaces: the
cross-instance phase is folded into the evaluation phase, because
decision 10 found that cross-instance edges cost nothing extra at
evaluation, and the phases after it move up one. Phase 1 also carries
a prerequisite which is not a phase of this plan: client-python-k3s
must learn the abort state, in its own pull request and release,
before this repository can emit one (decision 3).
| Phase | Plan | Status | Content | Merged |
|---|---|---|---|---|
| 0 | PLAN-agent-operation-dependencies-phase-00-decisions.md | In progress | Resolve the open questions into the decisions section above, and re-cut this table. Its survey found five of this plan's factual claims wrong or incomplete; its measurement found that instance queues do get deep but that no log can say whether a deep one held two submitters; its consumer audit found one firm break outside this repository and one single-commit obligation inside it | — |
| 1 | — | Not started | Schema, object and API: depends_on and runs_after on AgentOperation with an additive table migration, the agent edge model and VALID_DEPENDENCY_OPERATIONS (decision 5), per-edge settle seconds, abort in state_targets and TERMINAL_STATES in one commit (decision 3), the parameters on the three creating endpoints with their declarations and capability token, and submission-time validation (decision 6). Prerequisite: client-python-k3s has learned abort and released |
— |
| 2 | — | Not started | Dispatch evaluation (decisions 1, 2, 8, 9, 10): same-instance and cross-instance edges evaluated in the dispatcher between agent_operation_next() and start_instance_executor(), the cascade to abort, the settle delay, the dependency-wait budget and its configuration option, the failed dependency on the wire in external_view(), the database_load_budget.yaml amendment, and the terminal-dependency cache |
— |
| 3 | — | Not started | client-python: chain submission helpers, await-the-chain UX, failure reporting from the chain's first failure, and abort in TERMINAL_AGENT_OPERATION_STATES. Lands in the sibling repository, so its plan file lives there, following the PLAN-agent-operation-deadlines phase 6 precedent |
— |
| 4 | — | Not started | Documentation and examples (decision 11): a new docs/developer_guide/agent_operations.md for the model, the dependency vocabulary in the API reference, the lazy-cascade and swept-dependency ambiguities written down, the example index, and client-python-k3s adopting dependencies (rolling update as one submitted chain) as the first example application |
— |
| 5 | — | Not started | Push audit: runs PUSH-AUDIT.md over the accumulated diff of every phase in this plan against develop, not the last phase's diff alone. Findings land as their own pull request, and the plan is not complete until each is resolved or declined in writing here; if the audit finds nothing, that is recorded in one sentence |
— |
The Merged column records what put each phase on develop: the
merge commit of its pull request, or an explicit first..last range
where the phase landed directly. Nothing in this plan has landed, so
every cell reads —.
Future work¶
Work this plan has deliberately declined or deferred, so that it is not lost:
- Mixed-type dependency edges between agent operations and cluster operations, declined by decision 4 for the evaluation complexity rather than for want of a use case. Filed as #4824.
- A second example application for the agent operation example suite, to be chosen when there is a second feature worth exercising. Filed as #4825.
- A submitter identity on an agent operation. Decision 1 rests on an operating assumption which phase 0 could not measure, because nothing records who created an operation. A submitter field would make the question answerable and is not otherwise needed by this plan.
Bugs found during this work¶
agent_operations_queuein an instance's external view is always empty.Instance.external_view()reads thequeuedkey of theagent_operationsattribute, but every writer and every other reader usesqueue, so the field is empty and theinclude-queued-agent-operationscapability token advertises nothing. Found by phase 0's consumer audit, predates this plan, and filed as #4826 rather than fixed here.