Agent operation deadlines phase 7: documentation and CI coverage¶
Prompt¶
Plan the next phase of PLAN-agent-operation-deadlines.md with the
next-phase skill, after phase 6 merged as shakenfist#4005 and
client-python#380. Phases 1 to 5 built and enforced the timing budgets
server-side; phase 6 taught the client to propagate them and to fail
fast on a terminal state. This phase is the one that tells a human
what changed, proves the behaviour in functional CI, and pays off the
audit finding that this repository's own test suite awaits agent
operations with the very bug the plan exists to fix.
Planning effort¶
High. The volume is modest but two of the four code changes turn on correctness questions the master plan gets wrong, and one of them would be a regression if the master plan's instruction were followed literally (see decision 1). The functional coverage also has to be designed against a dispatcher whose one-executor-per-instance rule is the thing being proven, which is easy to write as a test that passes for the wrong reason.
Scope¶
In scope.
- The three event-renewed await loops in
shakenfist/deploy/shakenfist_ci/base.pygain an absolute ceiling as well as a progress window, and their renewal is restricted to events which represent progress. This is #3770. - The ten agent-operation poll loops in the two
test_agentops.pyfiles learn to fail fast on a terminal state, and stop sharing one clock across sequential operations. base.AGENT_OPERATION_FAILURESloses itsgetattr()compatibility shim now that client-python#380 has merged, and the twotest_get_missing_fileassertions narrow to the exception the new client actually promises.- Functional coverage in
shakenfist_cifor the four scenarios the master plan enumerates. - A new
docs/operator_guide/agent_operations.md, and an extension of the existingv07-v08.mdrelease-note section to cover phase 5's retry and phase 6's client-visible changes.
Out of scope.
docs/developer_guide/state_machine.md. It is a rendering ofstate_targets, so phases 4 and 5 updated it as they changed that dict rather than leaving the tree self-contradictory. The master plan's phase 7 row already says so.docs/developer_guide/api_reference/instances.mdand the existing release-note section on deadlines. Both were written by phase 3 and corrected since; see survey finding F1. This phase adds what is missing rather than rewriting what is there.- The push audit. That is phase 8, over the whole plan's accumulated diff.
- Any change to server-side enforcement. If the functional tests written here find an enforcement bug, it is filed and recorded in Future work, not fixed inline.
What the survey found¶
The master plan's phase 7 row was written before phases 3 to 6 executed and is wrong in four places. All four corrections are applied at source as part of the planning commit; a later step should not redo them.
F1 — the documentation is largely already written¶
The row says docs were "deferred from phase 3 so it is written once,
after enforcement exists". They were not. Phase 3's commit cf094551b
("Document agent operation timing parameters"), corrected by
83a0d2d0e, wrote both of the following:
docs/developer_guide/api_reference/instances.md:657-710— both parameters, their units, the omitted-versus-zero distinction, the defaults table, and whyagent/executerefuses a progress timeout.docs/release_notes/v07-v08.md:731-782— a full section, "Agent operations are bounded by a deadline, not a fixed backstop", covering the removal of the 900-second constant, both budgets, theexpiredstate, the three enforcement points, and an explicit warning that the effective default is tighter than the behaviour it replaces.
What is genuinely missing:
- Phase 5's retry and reaper are undocumented outside the plan
files.
AGENT_OPERATION_MAX_ATTEMPTSappears in exactly one published page,docs/developer_guide/state_machine.md:45. There is no release-note text for theEXECUTING -> QUEUEDedge, for attempt counting, or for the node-local executor reaper, and no operator guidance on what to do when an operation exhausts its attempts. - There is no operator-guide page for agent operations at all.
docs/operator_guide/has eighteen pages and none of them is about agent operations; the only operator-facing mention of the two defaults is a passing one indocs/operator_guide/database.md:1141, in the context of what the schema columns mean. - Phase 6's client-visible changes have no release note. The new
--deadlineand--progress-timeoutflags oninstance execute,uploadanddownload, and the behaviour change fromAgentCommandErrortoAgentOperationFailedon a terminal state, are user-visible and unannounced.
F2 — AGENT_OPERATION_FAILURES must not become a plain reference¶
The row instructs this phase to "narrow base.AGENT_OPERATION_FAILURES
to a plain AgentOperationFailed reference now that both client
generations no longer need tolerating". Half of that is right and half
of it is a regression.
The getattr() shim at
shakenfist/deploy/shakenfist_ci/base.py:46 genuinely can go:
client-python#380 merged on 2026-09-03, so AgentOperationFailed
always exists now. But the tuple has two kinds of consumer and they
want different things.
The two catch sites, base.py:469 and base.py:498, are
_await_instance_ready's cloud-init retry loop and its debug-gathering
loop. They mean "this attempt did not give us a usable answer, try
again". On client-python's current develop, await_agent_command()
raises three unrelated exception types, none of which shares a base
class beyond Exception:
AgentOperationFailed— the operation reached a terminal failure state (apiclient.py:1301, raised from_await_agentop).AgentAwaitTimeout— the operation never completed within the caller's own budget (apiclient.py:1773).AgentCommandError— the operation completed but the result is unusable: no results, unexpected stderr, no stdout blob (apiclient.py:1791,:1797,:1805,:1821).
Narrowing the tuple to AgentOperationFailed alone would stop the
retry loop catching the other two. A cloud-init health check whose
command writes to stderr would then escape the retry as an
AgentCommandError, and the whole _await_instance_ready would fail
on the first attempt instead of the third — a silent loss of retry
coverage across every test in the suite that boots an instance.
The assertion sites are the opposite case. test_get_missing_file in
both suites (smoke_ci_tests/test_agentops.py:306,
guest_ci_tests/test_agentops.py:315) asserts that fetching a
non-existent file raises something. A three-member tuple there
asserts almost nothing: it would pass if the operation timed out, or
if it succeeded and returned garbage. Those two want the narrow
reference.
Decision 1 resolves this.
F3 — there is a third event-renewed loop, and the addresses drifted¶
The master plan names two loops and gives addresses from before phase 4 and 5 landed. Current addresses, and the one it misses:
| Loop | Master plan says | Actually at | Window |
|---|---|---|---|
_await_agent_state |
base.py:436 |
base.py:522, loop at :531, renewal at :545 |
500s |
_await_instance_create |
base.py:471 |
base.py:557, loop at :561, renewal at :587 |
180s |
_await_objects_ready |
not mentioned | base.py:706, renewal at :724, check at :736 |
300s |
_await_objects_ready is the same construction — time.time() -
time_since_last_progress > 300, with time_since_last_progress
reassigned from last_event['timestamp'] for any event at all — and
it backs _await_networks_ready, _await_artifacts_ready and
_await_blobs_ready, so it is on the path of nearly every test in the
suite. It has to be fixed with the other two or the class of bug
survives.
The mechanism is confirmed rather than assumed. node_inst_op
(shakenfist/operations/node_inst_op.py:169) writes an
EVENT_TYPE_USAGE event against every instance on a timer, with
suppress_event_logging=True, so an instance which is going nowhere
still produces events forever. get_instance_events(..., limit=1)
returns the most recent event of any type, so that timer alone renews
the window indefinitely. The client already supports the fix:
get_instance_events() takes an event_type filter
(apiclient.py:570), gated on the events-by-type capability, and
the vocabulary is in shakenfist/constants.py:93-113.
_await_agent_state's timeout message also says "no progress in 5
minutes" for a 500-second (8m20s) window. _await_instance_create's
says three minutes for 180s and is correct.
F4 — the fix for the merge-queue flake was never applied to smoke¶
This one is not in the master plan at all and is the most valuable thing the survey turned up.
Commit a0cc243ad ("Give each agent operation its own timeout in the
put/get blob test", 2026-07-15) diagnosed an intermittent flake worth
~9% of merge-queue runs: test_instance_put_and_get_blob set one
start_time and then ran three sequential agent operations whose wait
loops all measured against it, so get-file — the last and heaviest —
was left only what the earlier two had not spent. The fix added
_await_agentop_complete(), which polls one operation against its own
fresh clock.
That commit touched guest_ci_tests/test_agentops.py and nothing
else. The identical test in smoke_ci_tests/test_agentops.py:162 still
has the bug: start_time is set once at :201, and the three loops at
:206 (30s), :217 (60s) and :235 (60s) all measure from it. The
smoke suite is the one that runs on every pull request.
The two files have identical test inventories — eight tests, same
names, same order — and are near-exact copies of each other. Any fix
applied to one and not the other is a fix that half-landed, which is
precisely what happened here. That is the argument for hoisting the
helper into base.py rather than copying it across (decision 2).
F5 — no functional coverage of the timing budgets exists¶
Grepping shakenfist/deploy/shakenfist_ci/ for deadline,
progress_timeout or expired finds only unrelated local variables,
the phase-4 hardening of _await_command (base.py:772-828), and the
AGENT_OPERATION_FAILURES comment. Nothing exercises an explicit
deadline_seconds, nothing asserts a deadline on external_view(),
and nothing observes an operation reaching expired. Phase 4's
_await_command work is the only CI-side change any phase of this
plan has made.
F6 — everything else the row claims is accurate¶
The audit finding that the suite's own loops "spin on != 'complete',
one with no timeout at all" holds, with one correction: the one with no
timeout at all was _await_command, and phase 4 already fixed it
(base.py:781, now with AGENT_OPERATION_TIMEOUT = 900 and
AGENT_OPERATION_FAILED_STATES). The ten that remain all have a
timeout and none has a terminal-state check.
Decisions¶
AGENT_OPERATION_FAILURESstays a tuple, and gains a member. It becomes(AgentCommandError, AgentOperationFailed, AgentAwaitTimeout)— the honest set of "an agent operation did not give us a usable answer" — with thegetattr()shim and its compatibility comment removed and replaced by a comment naming each member and the call inapiclient.pythat raises it. The twotest_get_missing_fileassertions narrow toapiclient.AgentOperationFailedon its own.
This is the decision a reviewer is most likely to argue with,
because it declines an explicit instruction in the master plan. The
reasoning is F2: the instruction conflates a catch site, which
wants breadth, with an assert site, which wants precision, and
following it literally silently disables the cloud-init retry for
the most common failure shape. AgentAwaitTimeout is added rather
than merely retained because it is new since the tuple was written
and belongs to the same category as the other two; the retry loop
should retry a timed-out health check.
- The poll helper is hoisted to
base.BaseTestCase, not copied._await_agentop_complete()moves fromguest_ci_tests/test_agentops.py:26tobase.py, gains a terminal-state check that routes through the existing_raise_agent_operation_failure()(base.py:805), and bothtest_agentops.pyfiles call it. F4 is what a copied fix looks like a month later.
The helper keeps its per-operation clock and its explanatory comment; the comment is the record of why the shared clock was wrong, and deleting it while fixing the same bug in the other file would lose that.
Correction (step 7a). The helper landed on
base.BaseNamespacedTestCase, not BaseTestCase as decided above.
It polls as self.test_client, and only
BaseNamespacedTestCase.setUp() constructs that attribute
(base.py:1072); leaving it on BaseTestCase would have been a
latent AttributeError for any non-namespaced caller that ever
reached it. The management session read step 7a's diff, made this
move, and amended the commit before it landed — see Review
follow-ups.
- The three event-renewed loops keep the progress window and gain a
ceiling. Both numbers are named constants on
BaseTestCase, so the ceiling is one place to change and the timeout message can quote the real number.
Renewal is restricted client-side, by reading a small window of
recent events per poll and picking the newest one whose type
represents progress. Step 7c rejected this plan's original
instruction to narrow in SQL by passing event_type to the events
call, and the reason is that the call takes one type, not a set:
get_instance_events, get_network_events, get_artifact_events
and get_blob_events all funnel into _get_events(...,
event_type, limit) (client-python:shakenfist_client/apiclient.py:542),
and the server is Optional[str] the whole way down
(external_api/base.py:object_events_response into
mariadb.get_object_events). Narrowing to a three-type set
server-side would therefore be three HTTP requests per poll per
object rather than one. This suite's events polling rate is already
a tracked load concern — shakenfist/deploy/shakenfist_ci/load_budget.py
excludes the harness's own events traffic from the database load
budget by hand, and says at length what that exclusion costs — so
tripling it to avoid a list comprehension in the harness is the
worse trade. The limit on the existing call goes from 1 to 10
instead, which is enough to see past a burst of periodic events,
and costs nothing extra on the wire.
Which types count as progress is the implementing step's call
against shakenfist/constants.py:93-113. The plan's position was
that status and mutate renew and the periodic channels
(usage, resources, health, prune) do not. Step 7c verified
that against what is actually written during instance create,
agent readiness and network, artifact and blob readiness, and
added audit to the set: between a network reaching initial
and reaching created the only events on the network object are
audit (network/bridged_vxlan_network.py), so status and
mutate alone would cover a network create by its two bracketing
state changes and nothing in between. The plan's position held for
the other three — mutate on every phase boundary, status on
the image and blob fetch percentages. Excluding the other four
costs nothing: resources and health are written only against
nodes, and prune and historic are never written at all.
The filter is not a proof, and the step's comment says so. Two
writers still emit a progress-typed event on a timer while nothing
progresses — blob.py's "waiting for existing download to
complete" audit every ten seconds, and the network maintain
daemon re-emitting "network not ok" status every thirty. That is
an argument for the ceiling, not for a narrower set: no event
filter can tell a wait that is progressing from one that is only
being talked about.
Because the narrowing is client-side, the events-by-type
capability gate is not reached for the type filter. It is still
reached for limit, which the loops already passed before this
phase, so nothing about the capability requirement changes.
-
The functional tests go in the smoke suite, in a new file. The four scenarios are server-behaviour tests, not guest-behaviour tests: they need a working in-guest agent, which the smoke suite already has (
smoke_ci_tests/test_agentops.pyexecutes commands today), and they need to run on every pull request to be worth having. A newsmoke_ci_tests/test_agentop_deadlines.pykeeps them out of the file that is duplicated into the guest suite, so this phase does not create a fifth F4. -
The expiry tests assert the state, not the timing. Each of the four scenarios asserts on
state == 'expired', on the presence and rough magnitude ofdeadline, or on a follow-up operation reachingexecuting— never on how many seconds something took. CI hardware is contended and a test that asserts an operation expired "within 10 seconds" is a flake being written on purpose. Waiting is bounded by the newbase.pyhelpers, which fail with a diagnosable dump rather than a bare timeout. -
The operator guide gets one new page, not additions to five.
docs/operator_guide/agent_operations.md, registered inmkdocs.ymlbetween "Scheduler" and "Threads", covering: what an agent operation is and the one-executor-per-instance rule; the two budgets and their three config defaults; theexpiredstate and how to tell it fromerror; retry,AGENT_OPERATION_MAX_ATTEMPTSand the node-local reaper; and what to tune when long agent commands are being killed. The existing passing mention indocs/operator_guide/database.md:1141gains a link rather than being duplicated. -
The release note is extended, not restructured. The existing section at
docs/release_notes/v07-v08.md:731stands as written. Two subsections are appended to it — one on retry and the reaper, one on the client — because they are the same story from a reader's point of view and a second top-level section about agent operation timing would invite the two to drift.
Step plan¶
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 7a | medium | sonnet | none | Hoist and harden the agent operation poll helper. Move _await_agentop_complete(self, instance_uuid, aop, timeout) from shakenfist/deploy/shakenfist_ci/guest_ci_tests/test_agentops.py:26 onto base.BaseTestCase in shakenfist/deploy/shakenfist_ci/base.py, next to _await_command at :781. Keep its existing docstring-comment verbatim — it records why a shared clock was wrong. Add a terminal-state check at the top of each iteration, using the existing AGENT_OPERATION_FAILED_STATES at :779 and calling the existing _raise_agent_operation_failure() at :805 (its command argument is used only for the message; pass a short description of the operation). Then route every remaining poll loop in both test files through it: smoke_ci_tests/test_agentops.py lines 51, 95, 146, 206, 217 and 235, and guest_ci_tests/test_agentops.py lines 36 (the old helper body), 67, 111 and 162. Note that smoke's test_instance_put_and_get_blob (:162) sets one start_time at :201 and shares it across the three loops at 206/217/235 — this is the bug commit a0cc243ad fixed in the guest copy only, so give those three the same independent budgets the guest copy uses (30/60/120, with the get-file operation getting 120). The two while time.time() - start_time < 120 loops (smoke :146, guest :162) are the same pattern written inside out and also become helper calls. Do not change any assertion. Commit subject: "Poll agent operations against one clock each." |
| 7b | low | sonnet | none | Retire the client compatibility shim. In shakenfist/deploy/shakenfist_ci/base.py, replace the AGENT_OPERATION_FAILURES definition at :44-46 and the comment block above it at :28-43. The tuple becomes (apiclient.AgentCommandError, apiclient.AgentOperationFailed, apiclient.AgentAwaitTimeout) with no getattr(). Write a new comment which says these are the three unrelated exceptions await_agent_command() and await_agent_fetch() can raise — a terminal failure state, a caller-budget timeout, and an unusable result — that they share no base class, and that this tuple is for catching ("the attempt gave us no usable answer, retry"), which is why it is not narrowed to the one the two catch sites at :469 and :498 most often see. Separately, narrow the two assertion sites: smoke_ci_tests/test_agentops.py:306 and guest_ci_tests/test_agentops.py:315 become apiclient.AgentOperationFailed (import apiclient the way those files reach base today, or reference it via base). Do not otherwise touch those tests. Commit subject: "Name the agent failures the client can raise." |
| 7c | high | opus | none | Give the event-renewed awaits an absolute ceiling (#3770). Three loops in shakenfist/deploy/shakenfist_ci/base.py renew their window from any event at all: _await_agent_state (:522, loop :531, renewal :545, 500s), _await_instance_create (:557, loop :561, renewal :587, 180s), and _await_objects_ready (:706, renewal :724, check :736, 300s). The renewal is unconditional because get_instance_events(..., limit=1) returns the most recent event of any type, and node_inst_op (shakenfist/operations/node_inst_op.py:169) writes a timer-driven EVENT_TYPE_USAGE event against every instance forever, so a stalled instance renews its own deadline indefinitely. Give each loop (a) a named absolute ceiling constant on BaseTestCase alongside the progress window, checked from a start_time taken before the loop; (b) renewal restricted to progress-representing events by passing event_type to the events call rather than filtering the returned list — get_instance_events/get_network_events/get_artifact_events/get_blob_events all take event_type (see apiclient.py:570), and the vocabulary is shakenfist/constants.py:93-113. Decision 3 in this plan says status and mutate renew and the periodic channels (usage, resources, health, prune) do not; confirm that against what actually gets written during instance create and object readiness before committing to it, and say in the commit message if you disagree. Note the client's _get_events() takes one event_type, not a list — check whether the server's object_events_response() accepts more than one before assuming two calls are needed. Ceilings should be generous relative to the existing windows (the point is to convert a silent budget fire into a diagnosable failure, not to tighten CI); the phase-4 precedent is AGENT_OPERATION_TIMEOUT = 900 at :772. Correct _await_agent_state's message, which says "no progress in 5 minutes" for a 500-second window, and make every message quote its constant rather than a hardcoded prose duration. There is a stray double space in _await_objects_ready's message at :744 (a trailing double space after "The last"); fix it while there. Commit subject: "Bound the CI awaits, not just their progress." |
| 7d | high | opus | none | Functional coverage for the timing budgets. New file shakenfist/deploy/shakenfist_ci/smoke_ci_tests/test_agentop_deadlines.py, modelled on smoke_ci_tests/test_agentops.py's class shape (base.BaseNamespacedTestCase, namespace_prefix, a network in setUp, base.CLUSTER_CI_IMAGE). Four tests, matching the four scenarios the master plan enumerates: (1) an explicit short deadline_seconds expiring an operation while it is still queued — enqueue a long agent/execute first so the instance's single executor is busy, then a second operation with a small deadline, and assert it reaches expired without ever executing; (2) the default deadline appears in external_view() — create an operation with no deadline_seconds and assert deadline is present and is roughly now + AGENT_OPERATION_DEFAULT_DEADLINE (600); (3) an agent/execute of a command that produces no output for well over the 30-second default progress timeout still completes, proving the progress timeout does not apply to commands that cannot report progress; (4) after an operation is expired, a follow-up operation on the same instance reaches executing promptly, proving the executor slot is freed. Assert on states and on the presence and rough magnitude of deadline, never on elapsed seconds (decision 5) — CI hardware is contended. Use the base helpers for all waiting, including _await_agentop_complete as hoisted by step 7a, so a failure prints the operation and the instance's recent events. The client parameters are deadline_seconds and progress_timeout_seconds on instance_execute/instance_put_blob/instance_get (client-python:shakenfist_client/apiclient.py:1409-1455); they are only sent when the server advertises the agentoperation-deadlines capability, which this repository's shakenfist/external_api/app.py:364 does. Read shakenfist/operations/agentoperation.py and shakenfist/daemons/sidechannel/main.py for what actually sets expired before writing the assertions. If a test cannot be made non-flaky, say so and leave it out rather than landing a known flake — this plan's whole subject is CI reliability. Commit subject: "Test agent operation deadlines in CI." |
| 7e | medium | sonnet | none | Documentation. Two files. First, new docs/operator_guide/agent_operations.md per decision 6, registered in mkdocs.yml in the operator guide nav (the list runs alphabetically at lines 494-509; insert "Agent Operations" after "Installation" to match the existing ordering, which puts Installation first and the rest alphabetically). Content: what an agent operation is and the one-executor-per-instance dispatch rule (shakenfist/daemons/sidechannel/main.py); the two budgets, AGENT_OPERATION_DEFAULT_DEADLINE (600) and AGENT_OPERATION_DEFAULT_PROGRESS_TIMEOUT (30), from shakenfist/config.py:240 and :259; the expired state and how an operator tells it from error (the state row's message says which budget ran out, and expire() audits it against the instance); retry, AGENT_OPERATION_MAX_ATTEMPTS (config.py:303) and the per-command retryable flag; the node-local executor reaper from phase 5; and what to tune when long agent commands are being killed. Link to docs/developer_guide/api_reference/instances.md for the request parameters rather than restating them, and add a link from the passing mention at docs/operator_guide/database.md:1141. Second, append two subsections to the existing release-note section at docs/release_notes/v07-v08.md:731 ("Agent operations are bounded by a deadline, not a fixed backstop") — do not restructure what is there. One subsection on phase 5: an operation which fails in executing for a retryable reason is now returned to the head of its instance's queue rather than abandoned, bounded by AGENT_OPERATION_MAX_ATTEMPTS (3), and a node-local reaper recovers operations whose executor died or never started. One on the client: sf-client instance execute, upload and download gained --deadline, and upload and download also --progress-timeout; and a terminal operation state now raises AgentOperationFailed immediately where the client previously polled to its full timeout and raised AgentCommandError, which is a behaviour change for anything catching that exception. Read the surrounding release-note prose and match its register — it explains consequences, not APIs. Commit subject: "Document agent operation timing for operators." |
| 7f | low | sonnet | none | Close the phase out. Set phase 7 to Complete in the Execution table of docs/plans/PLAN-agent-operation-deadlines.md and link this file from the row (the row currently has an empty Plan cell). Update this plan's Definition of done checkboxes to reflect what actually happened, and record anything deferred in Future work. In docs/plans/index.md, move the plan's row from 7 of 9 to 8 of 9, leaving the status at In progress — phase 8, the push audit, remains. Do not touch docs/plans/order.yml. Commit subject: "Close out agent operation deadlines phase 7." |
Corrections applied at source¶
Made in the planning commit, so a later step does not repeat them:
docs/plans/PLAN-agent-operation-deadlines.md, phase 7 row: the claim that documentation was deferred from phase 3 is corrected to name what phase 3 actually wrote and what is left (F1); the instruction to narrowAGENT_OPERATION_FAILURESto a plain reference is corrected to say the shim goes and the tuple stays, with a pointer to decision 1 (F2);_await_objects_readyis added to the list of event-renewed loops and the two stale addresses are refreshed (F3); the smoke-suite half ofa0cc243adis named as work this phase picks up (F4).docs/plans/PLAN-agent-operation-deadlines.md, the "#3770" section: the twobase.pyline numbers are refreshed and_await_objects_readyadded; the note that_await_commandwas pulled forward into phase 4 already stands and is left alone.docs/plans/index.md: the plan's row keeps7 of 9andIn progressuntil step 7f, but its Intent cell is left as it is — it describes the plan, not this phase, and is still accurate.
Risks and mitigations¶
- Step 7c tightens CI and turns a slow-but-passing test into a failure. The ceilings are new upper bounds on waits that previously had none. Mitigation: the ceilings are set generously relative to the existing progress windows, and the management session reads the diff for any ceiling that is not comfortably above the window it guards. The failure mode this converts from is a killed 60-minute job with no named test, so a too-tight ceiling is strictly easier to diagnose than the status quo.
- Step 7c's event-type filter starves a legitimate wait. If an object genuinely makes progress only through an event type the filter excludes, that wait now fails at its progress window where it previously renewed. Mitigation: the brief asks the implementer to confirm the type set against what is actually written during instance create and object readiness, rather than taking decision 3's list on faith, and to say so if they disagree. The ceiling is the safety net either way.
- Step 7d writes a flaky test. Expiry tests are timing tests wearing a disguise. Mitigation: decision 5 forbids elapsed-time assertions, and the brief explicitly authorises leaving a scenario out rather than landing a known flake. The management session runs the new file's tests against a real cluster before the phase closes, and a scenario that cannot be made reliable is recorded in Future work with its reason.
- Steps 7a and 7b both edit the two
test_agentops.pyfiles. Run them in order and re-read the second diff; they touch different lines but the same files, and 7a renumbers everything below its edits. - The functional tests cannot run in the management session. They need a cluster. Mitigation: the phase is not marked complete on unit tests alone — the Definition of done requires a CI run on the branch, and a failure there is a phase-7 defect, not a flake to re-queue.
Definition of done¶
Every item is checkable by running the command next to it. The final
item is the one that gates the merge: it is unticked below, and until
it is satisfied the four tests in
smoke_ci_tests/test_agentop_deadlines.py are unverified — step 7d's
own commit message says as much, and nothing a management session can
read substitutes for a run against a real cluster.
- No
getattr(apiclientremains in the CI suite:! grep -rn "getattr(apiclient" shakenfist/deploy/shakenfist_ci/ -
AGENT_OPERATION_FAILURESnames three exceptions and no shim:grep -A3 "^AGENT_OPERATION_FAILURES = (" shakenfist/deploy/shakenfist_ci/base.pyshowsAgentCommandError,AgentOperationFailedandAgentAwaitTimeout. - Both
test_get_missing_fileassertions name the narrow exception:grep -n "AgentOperationFailed" shakenfist/deploy/shakenfist_ci/*/test_agentops.pyreturns two lines, andgrep -c "AGENT_OPERATION_FAILURES" shakenfist/deploy/shakenfist_ci/*_ci_tests/test_agentops.pyreturns zero for both. -
No unguarded agent-operation poll loop remains in the suite:
grep -rn "while aop\['state'\] != 'complete'" shakenfist/deploy/shakenfist_ci/returns nothing.Correction: run as written, that command returns two lines, not nothing —
_await_command(base.py:914, phase 4's own terminal-state check) and_await_agentop_completeitself (base.py:1159). Both open with this exact condition and both checkAGENT_OPERATION_FAILED_STATESbefore looping again; the grep cannot distinguish a guarded loop from an unguarded one when both share the loop head's text. Read by hand, no loop that lacks a terminal-state check remains, which is the substantive claim this item is checking for. - [x] The helper is shared, not duplicated:grep -rn "def _await_agentop_complete" shakenfist/deploy/shakenfist_ci/returns exactly one line, inbase.py(onBaseNamespacedTestCase; see decision 2's correction). - [x] The smoke put/get test no longer shares a clock: insmoke_ci_tests/test_agentops.py,test_instance_put_and_get_blobcontains nostart_timereused across two waits. - [x] All three event-renewed loops have a ceiling: each of_await_agent_state,_await_instance_createand_await_objects_readyinbase.pyreferences a ceiling constant distinct from its progress window. - [x] No timeout message states a duration that disagrees with its constant: read the three messages and check each against the constant it quotes._await_agent_state's "5 minutes" for 500 seconds is the known instance. All three messages now build their duration from_await_expired()'s f-strings, which quote the real constant rather than hardcoded prose. - [x] Renewal only happens on progress events: every one of the three loops renews through_renew_progress(), andgrep -n "limit=1)" shakenfist/deploy/shakenfist_ci/base.pyreturns nothing — no renewal call reads a single event of any type any more.PROGRESS_EVENT_TYPESonBaseTestCasenames the set, and excludesusage,resources,healthandprune. - [x]smoke_ci_tests/test_agentop_deadlines.pyexists and contains four tests, or fewer with each omission recorded in Future work and its reason given. All four scenarios are present; none was omitted. - [x]docs/operator_guide/agent_operations.mdexists, is listed inmkdocs.yml, and names all three ofAGENT_OPERATION_DEFAULT_DEADLINE,AGENT_OPERATION_DEFAULT_PROGRESS_TIMEOUTandAGENT_OPERATION_MAX_ATTEMPTS:grep -c "AGENT_OPERATION_" docs/operator_guide/agent_operations.mdis at least 3. - [x] The release note covers retry and the client:grep -n "AGENT_OPERATION_MAX_ATTEMPTS\|--progress-timeout\|AgentOperationFailed" docs/release_notes/v07-v08.mdreturns at least one line for each. - [x] No fact about the timing budgets is stated differently on two pages: the defaults quoted indocs/operator_guide/agent_operations.md,docs/developer_guide/api_reference/instances.md:680anddocs/release_notes/v07-v08.md:744agree (600 seconds and 30 seconds, in all three). - [x]pre-commit run --all-filesexits zero. - [x] CI has run green on the branch, including the smoke suite, and the new deadline tests appear in its results rather than being skipped. Satisfied by run 33802668130, whoseSmoke tests (collection)job reports all four as... ok:test_default_deadline_is_published(166.69s),test_expiry_frees_the_executor_slot(176.77s),test_silent_execute_survives_the_progress_timeout(227.14s) andtest_queued_operation_expires_on_its_deadline(163.33s). None was skipped. PR #4053 merged as4a122bcd3on 2026-09-04.
Review follow-ups¶
After step 7a landed, the management session read its diff and moved
_await_agentop_complete() from BaseTestCase to
BaseNamespacedTestCase, then amended the commit before continuing to
7b. The reason is decision 2's correction above: the helper polls as
self.test_client, which only BaseNamespacedTestCase.setUp()
constructs, and leaving it on BaseTestCase would have been a latent
AttributeError for the first non-namespaced caller to reach it.
What remains¶
Nothing. The code and documentation were done and reviewed by five
commits, 7a through 7e, and the closeout re-ran every Definition of
done check that does not need a cluster. The one that does -- a green
CI run including the smoke suite, with the four tests in
test_agentop_deadlines.py appearing in the results rather than being
skipped -- was satisfied by run 33802668130, and PR #4053 merged as
4a122bcd3 on 2026-09-04.
The section above previously said the branch had not been pushed and
that the statuses would stay at In progress and 7 of 9 until it
was. Step 7f, which was to make that flip, never ran; the phase 8
planning commit made it instead, so phase 7 now reads Complete in
PLAN-agent-operation-deadlines.md's Execution table and the plan's
row in docs/plans/index.md reads 8 of 9. See
PLAN-agent-operation-deadlines-phase-08-push-audit.md,
finding F1.
Future work¶
- The master plan's phase 7 row observes that
_await_instance_readyretries a cloud-init health check three times with a 30-second sleep between attempts. If step 7c's event-type filter turns out to change how often that path is entered, the retry count deserves a second look — but not in this phase. docs/operator_guide/has two pages that are not inmkdocs.yml's nav (credential_rotation.md,vdi_console_tokens.md). Noticed while adding the new page; unrelated to this plan and not fixed here.- The two
test_agentops.pyfiles are near-exact copies with identical test inventories. F4 shows what that costs. Collapsing them into one parameterised module is a larger change than this phase should carry, and belongs to whoever next has a reason to touch both. - A latent regression from phase 6, repaired here by accident.
Phase 6 changed
await_agent_command()'s timeout path in client-python from raisingAgentCommandErrorto raising the newAgentAwaitTimeout.base.AGENT_OPERATION_FAILURESdid not name the new exception, so between phase 6 landing and step 7b,_await_instance_ready's cloud-init retry loop silently stopped retrying a timed-out health check — it had retried them before, asAgentCommandError. Step 7b restores that coverage by addingAgentAwaitTimeoutto the tuple. Worth naming because it is a general hazard: changing which exception a client raises silently narrows everyexcepttuple downstream that was written against the old one. - The two
test_agentops.pyfiles diverged slightly rather than converging, which the entry above already names as a duplication hazard. The specific new drift: step 7a gave the smoke suite'stest_instance_put_and_get_blobindependent budgets of 30/60/120, while the guest copy keeps 60/60/120, because each preserved its own original first-operation budget. The shared-clock bug is gone from both, but the files are still near-exact copies with one number different. - Decision 3 was corrected during execution, by step 7c, and the correction is written into the decision itself. Noted here only so a reader does not go looking for an uncorrected copy elsewhere.
- Step 7d found no server-side enforcement bug, which this plan's
out-of-scope section anticipated it might. Record the one thing it
did establish that the documentation now relies on:
agent/executenever goes through preflight — the endpoint sets the queued state directly — so an expired execute was expired either at dequeue or inside the executor, never at the preflight check that bounds other operation types. - Step 7e found and fixed two stale documentation facts outside
this phase's stated scope, both licensed by the "no fact stated
differently on two pages" Definition-of-done criterion:
docs/operator_guide/database.mdstill saidattemptsandlast_progresswere recorded but not yet consumed, anddocs/developer_guide/api_reference/instances.mdstill said nothing is retried. Both were true before phase 5 and false after it.
Back brief¶
Before executing any step of this plan, back brief the operator on your understanding of it and how the work you intend to do aligns with it.
There is one gate. Decision 1 declines an explicit instruction in the master plan, on the grounds that following it literally would disable the cloud-init retry loop for the most common failure shape. Step 7b implements that decision. Confirm decision 1 before starting 7b. Steps 7a, 7c, 7d and 7e do not depend on it and can proceed meanwhile.