fix(foreman): free the agent's in-process slot while a Job-mode task runs - #1635
Conversation
…runs The AgenticTaskWatcher held one process-wide in-flight slot for every task it claimed, so a Job-mode AgenticTask blocked all other work on its node for the whole lifetime of its ephemeral Job -- even though the loop, workspace and toolchain run inside the Job pod and the agent process only submits, polls Job.Status and tails logs. defilantech#1558 stopped the controller reserving a FleetNode for Job-mode tasks; without the agent-side half the node still refused to pick up a second task. In-process runs keep the strict one-at-a-time slot: they own this process's CPU, memory and workspace, which is what the slot was for. Job-mode runs get their own budget instead, bounded rather than unbounded -- each supervision is an outstanding coder Job competing for pods, cache volumes and inference capacity -- defaulting to 4 and tunable with --max-supervised-tasks. That bound is per node and composes with Agent.spec.maxConcurrentTasks, which is per Agent and enforced by the controller before a task is ever Scheduled. The watcher asks the Executor which mode a task will take, through a new optional SupervisingExecutor interface implemented over the same useCoderJobPath predicate Execute dispatches on, so the slot accounting cannot drift from the path actually taken. An Executor that does not implement it (the stub) is treated as in-process. Refs defilantech#1559 Signed-off-by: Jory Irving <jory@jory.dev> Document the split in docs/site/foreman/README.md: a node runs one in-process task and may supervise several Job-mode ones, FleetNode.status.currentTask reports only the former, and --max-supervised-tasks is per node while Agent.spec.maxConcurrentTasks is per Agent. Signed-off-by: Jory Irving <jory@jory.dev>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
Thanks Jory, this is a good catch and a well-shaped fix. #1559 has been quietly costing us a slot per Job-mode run and I appreciate you going after the accounting rather than papering over it with a bigger pool. The branch compiles clean, I went through it carefully and have a handful of things, one of which I think has to change before merge. Everything else is negotiable or a follow-up, and I have marked which is which. Blocking: the error path reintroduces the bug it fixes
The two independent resolutions break the invariant the other way too. Edit I would rather we resolve the Agent once and pass the decision through to Blocking: the headline behaviour is not covered
Keying Please fix before merge, but smallThe new tunable does not reach the agents that need it.
Poll loop got more expensive than intendedTwo related things, and I do not think either was deliberate:
_, canSupervise := w.Executor.(SupervisingExecutor)
if !w.hasCapacityFor(false) && (!canSupervise || !w.hasCapacityFor(true)) {
return nil
}
Accuracy nits worth correcting while we are here
Follow-ups, happy for these to be separate
Tests, minor
Thanks again for this. The diagnosis is right and the shape of the fix is right; it is the error path and the test matrix that need another pass. Happy to talk through the resolve-once approach if you want to sketch it before writing it. |
|
Thanks Chris, you're right on all of it. Taking the error path first, because your framing corrected mine. I had Sketch of what I intend, tell me if you'd cut it differently. The watcher resolves the Agent once for the candidate it is about to claim, and derives the mode from that single read. It then hands the resolved Agent to On the failure case: if that single GET fails, the right answer is to not claim the task at all. Skip the candidate, log the error, let the next poll retry. Unknown mode should mean no claim, not a guessed claim, which I think is the actual fail-closed behaviour I thought I'd written. One consequence worth flagging early since it touches your dead-short-circuit fix: if On the tests, agreed and it's the more embarrassing of the two. The chart gap is mine and it's a bad one: I documented a knob in the same PR that adds it and never checked an operator could reach it. Same for The dead short-circuit I straightforwardly did not think about. Roughly 96 pointless Lists a minute on my fleet is a regression I introduced and your guard is the fix. On the README workspace claim, thank you for pushing on this rather than letting it go as a wording nit. It was fabricated. Taking the Both follow-ups make sense as separate issues and I'll file them. The second one is the one I keep coming back to as well: a process-local int is a strange place for capacity to live when the cluster's model is still a single I've started on everything except the two blocking items, which I'll hold until we've agreed the resolve-once shape. |
The supervision bound shipped as a flag only, so chart-managed in-cluster agents -- the ones actually running Job-mode coders -- were pinned at the built-in default of 4 while the docs told operators to tune it. Add a per-pool maxSupervisedTasks key, rendered like the other optional args so leaving it unset changes nothing. Also nail down what 0 means. maxSupervised() maps anything <= 0 to the default, so document that: 0 is UNSET, not "supervise nothing" -- the latter would wedge every Job-mode task on the node, and the watcher's neighbouring Interval and TaskLivenessInterval fields already use the same zero-value-means-default convention. An operator who wants the old serialized behaviour passes 1, which the flag help now says. Refs defilantech#1559 Signed-off-by: Jory Irving <jory@jory.dev>
Three statements about the concurrency model no longer hold, and each one invites a wrong conclusion. The README justified the single in-process slot with a workspace collision. There is none: executor_native.go joins workspaceRoot/namespace/task-name, resets it before the run and removes it after, so two in-process runs never share a directory. Publishing a mechanism that does not exist invites someone to lift the limit on the grounds they have fixed a collision that was never there. The real constraint is host CPU and RAM plus the shared toolchain, so say that instead. FleetNode.status.currentTask still documented itself as "empty if idle". Since defilantech#1496 a Job-mode task deliberately leaves the field untouched, and since defilantech#1559 one agent supervises several of them, so an empty CurrentTask means "free for in-process work", not "idle" -- which is what kubectl explain and the shipped CRD were telling operators. Regenerated. The chart's Recreate comment reasoned about a blast radius of one task. recoverOrphanedTasks resets every Running task on the node, which is now the in-process run plus up to --max-supervised-tasks supervisions. Refs defilantech#1559 Signed-off-by: Jory Irving <jory@jory.dev>
Three review points on the defilantech#1559 tests. The error-path test read w.supervised and w.inflight directly. AGENTS.md asks for observable behaviour, and the observable form of "the slot was released" is already used elsewhere in the file: a follow-up pollOnce can claim another task. Asserting that instead survives a move to a semaphore or a channel. The watcher gets one supervision slot so a leak actually blocks the follow-up claim rather than hiding under the default of 4, and the poll is retried because the slot is released after the terminal patch, not before it. t.Cleanup(close(release)) unblocked Execute goroutines that nothing joined, so they patched status and logged after the test body returned. Add a WaitGroup, waited on after the close, plus a started channel so every launched goroutine has entered Execute (and done its Add) before the cleanup Waits. The duplicate CoderJobSubmitter double stays: executor_native_test.go's fakeCoderJobSubmitter is in package agent_test and this file is in package agent, so it cannot be reached from here. Noted at the type instead. Signed-off-by: Jory Irving <jory@jory.dev>
Two API-call regressions from the slot split, both on the hot path and the agent's client is uncached. The "we are busy, skip the List" short-circuit stopped firing, because hasCapacityFor(true) is true whenever supervised < 4. A stub-mode node, or any node whose Executor cannot supervise at all, therefore issued a full namespace List every poll interval for the whole run and then skipped every candidate. Fold the Executor's capability into the guard: an idle supervision budget only counts when there is something that could spend it. pollOnce also asked the Executor for a task's execution mode once per Scheduled candidate, including candidates it could not claim -- each an uncached Agent GET, and the candidates queued on one node nearly always share a single Agent. Memoize the answer per pass, keyed by the task's namespace-qualified agentRef, since the mode is a property of the Agent. Both are pinned by regression tests: the List count with the only slot busy, and the number of mode lookups for several candidates behind one Agent. Refs defilantech#1559 Signed-off-by: Jory Irving <jory@jory.dev>
|
Yes to the resolve-once shape. Resolve the Agent once for the candidate about to The four commits you pushed all look right to me. The chart knob reaches agents On
|
|
One small follow-up from the collision I flagged earlier, now that the identity Your current wording keeps "stops a rolling update from briefly running two The blast-radius half of your comment — one in-process task plus up to Sequencing note: #1647 touches the same region (it makes the strategy |
|
Both of your refinements are better than what I sketched, and one of them catches a regression I would have shipped. Keeping The Noted on the guard assumption. I will record that type-has-method and instance-can-supervise agree today by wiring rather than construction, with the nil-submitter Taking your Also taking another look at Working the blocking pair now: resolve-once in the shape above, and the mixed-mode matrix keyed off the task, covering the in-process-holding-inflight-while-a-Job-mode-task-is-claimed case and the supervising-plus-in-process-claim case, plus the not-found and transient-error paths. |
The watcher asked the executor which slot a candidate needed, and Execute then resolved the Agent again for itself. Two independent uncached GETs can disagree: a transient failure on the first one answered "in-process" for a Job-mode task, so the node held its only slot for the coder Job's whole lifetime -- defilantech#1559 again, with the error swallowed. Editing the Agent between the two reads broke the invariant the other way. The dispatch loop now reads the Agent once for the candidate it is about to claim, derives the slot from that value, and hands the same value to Execute, so one predicate over one value decides both. SupervisingExecutor stays, but SupervisesAgent takes the resolved Agent and does no I/O; the predicate has to live on the executor because it is a conjunction of the instance's submitter wiring and the Agent's mode, and computing it in the watcher would leak the former into the dispatch loop. A read that fails ambiguously leaves the candidate Scheduled for the next poll: an unknown mode must not be guessed. A DELETED Agent is not that case -- it resolves to a nil Agent, the task is still claimed, and Execute stamps FailureAgentNotFound as before, because a task whose Agent is gone deserves a verdict rather than sitting at Scheduled with nothing saying why. Signed-off-by: Jory Irving <jory@jory.dev>
…plit modeExecutor answered one fixed mode for every task, so both concurrency tests ran a uniform fleet and the case the slot split exists for was never exercised. It now answers per Agent, which is where mode actually lives, and the fleet can be mixed. Adds the two directions of that: a Job-mode task claimed while an in-process run holds inflight (defilantech#1559 itself), and an in-process task claimed while a supervision is outstanding (the README's claim that a supervising node can still take a review or gate task). Both also assert the resolved Agent reached Execute. Adds the failure split too: a deleted Agent is claimed and handed nil, an ambiguous read leaves the candidate Scheduled until it succeeds. The per-pass memo assertion moves off the executor's call count and onto the Agent GETs it saves, which is the cost that motivated it. Signed-off-by: Jory Irving <jory@jory.dev>
The comment justified Recreate by two agents racing for the same Scheduled tasks. That race cannot happen: FLEET_NODE_NAME is dead wiring, so each replica registers its own FleetNode from its pod name, and pollOnce claims only tasks assigned to its own node (defilantech#1640, resolved to process-scoped identity in defilantech#1649). Wording per the maintainer's suggestion on defilantech#1635; the blast-radius half is unchanged. Signed-off-by: Jory Irving <jory@jory.dev>
Defilan
left a comment
There was a problem hiding this comment.
Approving. All twenty items are addressed, and I checked each against the branch rather than reading the summary.
The two blockers are genuinely closed. resolveTaskAgent is now the single read and Execute/SupervisesAgent consume the value it returns, so the two independent resolutions that reintroduced #1559 are gone; the mixed-fleet gap is covered in both directions at watcher_concurrency_test.go:475. Both perf regressions are fixed the way we discussed: the poll guard asserts zero Lists over three polls, and the per-pass memo asserts one GET for three candidates.
I spent most of the time on the concurrency, since that is where this PR can hurt. It holds up:
- Release is exactly once, on every path. Acquire is synchronous in
launchExecutorbefore the goroutine starts, and the releasedeferis registered first inside it, so LIFO puts it afterpatchTerminal. That also means there is no race between freeing the slot and recording the terminal status, which was the thing I was most worried about. Error return, context cancellation and panic unwinding all pass through it, and the PR adds nodeferinside a loop. - No early release.
Submitblocks until the Job reaches a terminal phase, so the supervision slot is held for the Job's whole lifetime, bounded byPollTimeout. - No second writer to
FleetNode.status.CurrentTaskis still written only by the controller'sreserveNode/clearNodeCurrentTask, the heartbeat only by the registrar's own loop, and the budget is a process-local counter underinflightMu. The part that makes this actually safe rather than accidentally safe is thatreserveFirstFitNodedeliberately does not stampCurrentTaskfor Job-mode, so a second claimed task never contends for a single-valued field. - Check-then-act is sound because only
pollOncereserves and it is single-threaded, while executor goroutines only release. Capacity can only grow between the check and the reserve.
I also confirmed the restart story: recoverOrphanedTasks resets Running to Pending, and reapPreviousJobs deletes the previous Job, so there is no double-run. The blast radius growing from 1 to 1+N is real and the chart comment says so plainly.
Gates on your head commit: build, vet, full go test (35 packages, zero failures), -race over the new tests five times, golangci-lint clean on darwin and GOOS=linux, helm unittest 42/42.
On test quality, I mutated the ten load-bearing behaviours and every one was caught. The two that matter most: making supervises() always false, which is the #1559 bug itself, fails three tests; and flipping IsNotFound to a blanket skip, which is the regression I flagged in the first round, fails TestPollOnce_DeletedAgentIsStillClaimed. No test in the new suite reads w.supervised or w.inflight directly, so T1 is honoured, and stubCoderJobSubmitter is gone as you predicted on its third flag.
Thanks for filing #1638 and #1639 rather than widening this PR into them, and for taking SupervisesAgent over the alternative.
Five minor things, none blocking, take or leave:
cmd/foreman-agent/main.go:504logs the raw flag rather than the resolved value, so an operator who passes--max-supervised-tasks=0seesmaxSupervisedTasks: 0at startup while the agent actually runs 4. That is exactly the confusion the=0doc change was about, surviving in the one place an operator looks to confirm it.executor_coderjob.go:127dereferencesagent.Specwith no nil check. Safe today via the two call-site guards, but the thesis of this PR is one predicate and one value, so the guard belongs inside the predicate.watcher_concurrency_test.go:108callse.wg.Add(1)inside the launched goroutine, sot.Cleanup'swg.Wait()is only sound because every test'swaitStarted(n)matches its claim count exactly. They all do today, and I checked all eight. A future test that counts low reintroduces the unjoined-goroutine hazard. Pre-adding outside the goroutine makes it structural instead of hand-maintained.- Worth a note on #1638: the per-pass memo hands one shared
*Agentpointer to callers. Nothing writes to it today andpollOncereturns after a single claim, so it is fine now, but multi-claim off one memo entry would make it shared across goroutines. - Sequencing only: the
Recreatecomment collides with #1647 and #1438. Whoever lands second rebases, and #1647's comment needs to keep your blast-radius text, since an agent holding one in-process task plus N supervisions is exactly what makes a rolling update yank back more than one run.
…mkube (0.9.19 ➔ 0.9.20) (#402) This PR contains the following updates: | Package | Update | Change | |---|---|---| | [ghcr.io/home-operations/charts-mirror/llmkube](https://github.com/defilantech/LLMKube) | patch | `0.9.19` → `0.9.20` | --- ### Release Notes <details> <summary>defilantech/LLMKube (ghcr.io/home-operations/charts-mirror/llmkube)</summary> ### [`v0.9.20`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#0920-2026-08-24) [Compare Source](defilantech/LLMKube@v0.9.19...v0.9.20) ##### Features - **foreman:** opt-in archival of task audit records and transcripts ([#​1655](defilantech/LLMKube#1655)) ([c577133](defilantech/LLMKube@c577133)) - **foreman:** report the Kubernetes node on FleetNode.status, not as identity ([#​1649](defilantech/LLMKube#1649)) ([7563e8a](defilantech/LLMKube@7563e8a)) ##### Bug Fixes - **controller:** carry Model tolerations onto the prefetch Job ([#​1622](defilantech/LLMKube#1622)) ([d0db838](defilantech/LLMKube@d0db838)) - **controller:** clear the controller's own schedulingStatus once a service is Ready ([#​1633](defilantech/LLMKube#1633)) ([315f34d](defilantech/LLMKube@315f34d)) - Empty payload.repo silently defeats the upstream-base fetch: task branches cut from a stale fork HEAD ([#​1625](defilantech/LLMKube#1625)) ([#​1626](defilantech/LLMKube#1626)) ([27a015a](defilantech/LLMKube@27a015a)) - **foreman:** carry the in-pod result extras on every Job-mode branch ([#​1657](defilantech/LLMKube#1657)) ([d1ce51c](defilantech/LLMKube@d1ce51c)) - **foreman:** free the agent's in-process slot while a Job-mode task runs ([#​1635](defilantech/LLMKube#1635)) ([0d64fbb](defilantech/LLMKube@0d64fbb)) - **foreman:** gate the make-invoked CI checks, and pin them against the workflows ([#​1642](defilantech/LLMKube#1642)) ([4e7ab7b](defilantech/LLMKube@4e7ab7b)) - **foreman:** make the CustomResourceState config produce usable metrics ([#​1650](defilantech/LLMKube#1650)) ([1620443](defilantech/LLMKube@1620443)) ##### Documentation - add ROCm host-retune runbook for >64GB on Strix Halo ([#​1387](defilantech/LLMKube#1387)) ([fff5e59](defilantech/LLMKube@fff5e59)) - fix guide instructions that fail against the shipped API ([#​1629](defilantech/LLMKube#1629)) ([fba0bf4](defilantech/LLMKube@fba0bf4)) - **proposals:** foreman run, an unattended orchestration loop ([#​1652](defilantech/LLMKube#1652)) ([df57f49](defilantech/LLMKube@df57f49)) - reframe the multi-GPU guide from an Issue [#​2](defilantech/LLMKube#2) validation plan into a deployment guide ([#​1631](defilantech/LLMKube#1631)) ([907ac8d](defilantech/LLMKube@907ac8d)) - **runbook:** correct the metal-agent memory-pressure runbook against pkg/agent ([#​1630](defilantech/LLMKube#1630)) ([f665674](defilantech/LLMKube@f665674)) - serving one model across two DGX Sparks via llama.cpp RPC ([#​1620](defilantech/LLMKube#1620)) ([7eaf04b](defilantech/LLMKube@7eaf04b)) </details> --- ### Configuration 📅 **Schedule**: (in timezone America/New_York) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC40MC4wIiwidXBkYXRlZEluVmVyIjoiNDQuNDAuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsicmVub3ZhdGUvY29udGFpbmVyIiwidHlwZS9wYXRjaCJdfQ==--> Reviewed-on: https://git.greyrock.io/greyrock-labs/home-ops/pulls/402
What
Stop a Job-mode AgenticTask from holding the agent's single in-process execution
slot for the lifetime of its Job.
Why
Fixes #1559
#1558 stopped the controller reserving a FleetNode for Job-mode tasks. That was
necessary but not sufficient: the controller can now place a second task on the
node, but the agent will not pick it up, because
AgenticTaskWatcherholds oneprocess-wide
inflightslot for every task it claims regardless of executionmode.
Observed on an 8-node fleet: one node ran a Job-mode coder task with two further
tasks stuck in
Scheduledbehind it while the other seven FleetNodes satReadywith nothing assigned. The supervising agent process was idle throughout — the
coder loop, workspace and toolchain all run inside the Job pod.
How
In-process runs keep the strict one-at-a-time slot: they own the agent process's
CPU, memory and workspace directory, which is what the slot was for.
Job-mode runs get a separate budget,
MaxSupervisedTasks, default 4, settablewith
--max-supervised-tasks. Bounded rather than unbounded because eachsupervision holds a goroutine and a liveness probe, and more importantly each is
an outstanding coder Job competing for pods, cache volumes and inference
capacity. Nothing else caps that per node.
The bound is per node and composes with
Agent.spec.maxConcurrentTasks(#1497),which is per Agent and enforced by the controller before a task is ever
Scheduled. Different axes; whichever is tighter wins. A task the controllerdeclines never reaches the watcher.
The watcher learns a task's mode by asking the Executor, through a new optional
SupervisingExecutorinterface implemented over the sameuseCoderJobPathpredicate
Executealready dispatches on, so slot accounting cannot drift fromthe path actually taken. This is deliberately not a watcher-side
spec.execution.modecheck: a Job-mode Agent whose executor has no wiredCoderJobSubmitterruns in-process, and a mode check would hand out a slot itisn't holding. Executors that don't implement the interface (
StubExecutor)are treated as in-process, so stub mode stays strictly serialised.
The reservation happens before the goroutine starts and the release is a
deferregistered first inside it, so the slot is returned on every exit path —
executor error, context cancellation, or panic.
Out of scope
reserveFirstFitNodereturns the alphabetically first eligible node forJob-mode tasks and, correctly per #1496, does not reserve it — so Job-mode tasks
concentrate on a single node (measured: 10 of 11 in one day, with seven nodes
idle). This change raises that node's concurrency from 1 to
MaxSupervisedTasks, but does not spread work across the fleet, which means theper-node bound currently behaves like a fleet-wide cap. Filed separately as
#1634. Happy to fold the two together if you'd rather they land as one change.
Also worth knowing:
FleetNode.status.currentTaskstill reports only thein-process task, so a node supervising Jobs looks idle in
kubectl get fleetnode. That follows from #1558's design rather than this change, and is nowdocumented.
Checklist
make testpasses locallymake lintpasses locallygit commit -s) per DCOmake lint-allalso passes (GOOS=darwinandGOOS=linux, 0 issues).Tests:
pkg/foreman/agentcovers in-process serialisation, Job-modeconcurrency, the supervision bound holding a third task at
Scheduled, slotrelease on the executor-error path for both modes, and a 5-case table on
SupervisesExternally(submitter wired, no submitter, in-process Agent, missingAgent, no
agentRef). Verified the new tests fail with the fix reverted:stubbing the mode check to always return in-process gives
want 2 got 1on both concurrency tests.Not manually exercised in a cluster yet — the fleet behaviour above is the
observation that motivated the fix, not a verification of it.
Assisted-by: Claude Opus (wrote the implementation, tests and docs; I reviewed
the diff, confirmed the tests fail without the fix, and ran make test / lint /
lint-all)