Repository navigation
fix(metal): report failed starts and act on engine exits at once - #2436
Merged
Defilan merged 6 commits intoNov 14, 2026
Merged
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…ackoff A start that failed for a reason that is neither a refusal nor a transient API error (an engine that exits while loading, a download that hits a full disk, a startup timeout) was only logged: no Event, no status write, no retry. The InferenceService kept the controller's "Waiting for the host metal-agent to fetch the model" until something else touched it. Such a failure now writes StartFailed to status.schedulingStatus, listed with the agent refusal reasons so the controller keeps it, emits a StartFailed Warning Event with the error and the engine log tail, and is retried on the health-restart backoff (10s, doubling up to 5 minutes). A delete or scale to zero cancels the retry and a spec change starts the new spec at once, as for a health restart. A retry that fails emits RestartFailed, which now carries the bounded log tail as well, and the status is cleared once an engine runs. Signed-off-by: Christopher Maher <chris@mahercode.io>
…run's log When an engine process exited, the reaper recorded the exit and nothing acted on it. The endpoint stayed Ready until the next health probe (every 30s), the restart waited for three failed probes (about 90s), the exit status was never logged, and the restart truncated the engine log, erasing the crashed run's output. The probe threshold is right for a live engine that is slow to answer; a process that is gone gains nothing from it. The agent now watches the reaper of each engine it stores. When a tracked engine exits, it withdraws the endpoint at once, logs the exit status, and restarts the engine on the restart backoff without waiting for the probe threshold, with an EngineExited Warning Event carrying the exit status and the log tail. An exit while another start holds the service is retried once the start is done, withdrawing again in case that start registered the endpoint. An engine the agent stops itself is untracked before the stop, so its exit is ignored. An engine adopted from a previous agent process is not a child, so on macOS its PID is watched with kqueue (EVFILT_PROC, NOTE_EXIT) after its identity is re-proven; its exit status is not available. Elsewhere the probe stays the detector. Opening an engine log now moves the previous one to <log>.1 (one generation) instead of truncating it, keeping O_NOFOLLOW and mode 0600: a symlink at the log path is still refused, and one planted at <log>.1 is replaced, never written through. Signed-off-by: Christopher Maher <chris@mahercode.io>
The StartFailed, RestartFailed and EngineExited Events carried the last lines of the engine log. Events are readable by anyone who can read the namespace and are commonly exported to central logging, and an engine log can carry request text, so prompts could leak through them. Those Events, and the StartFailed scheduling message, now carry only the error's first line without any attached log tail, cut to 200 bytes, plus the exit status, the retry delay and the path of the engine log on the Mac (<log> and <log>.1). The full tail stays in the agent's own log, which now also records it for an engine that exits. Signed-off-by: Christopher Maher <chris@mahercode.io>
…exits Since defilantech#2462 an engine that is running but misses its probe at adoption is adopted unhealthy instead of stopped. Its exit is watched like a healthy adopted engine's, so the restart is queued at once and one EngineExited Event is emitted. The test fails if the exit watch skips unhealthy engines. Signed-off-by: Christopher Maher <chris@mahercode.io>
…ually guarantee MetalAgentRefusalReasons and the controller's agentRefusalReasons now hold StartFailed, which is not a refusal, so their comments say they list the statuses the agent writes and clears itself. markExited's comment claimed a late probe cannot mark an exited engine healthy; it can, for at most restartGuardRetry, and the comment now says so. The README gives the engine log name and says an exit restart backs off like StartFailed. The adopted-engine test reads the process under the lock and checks the endpoint only where one was registered. Signed-off-by: Christopher Maher <chris@mahercode.io>
Defilan
force-pushed
the
fix/metal-report-failed-starts-and-exits
branch
from
November 13, 2026 08:33
09c408c to
339e31b
Compare
…onds The delay is computed from a deadline, so on a real clock it carries the nanoseconds spent in between. A test on an M6 Mac showed "restarting in 9.999996916s" and "retrying in 1m19.99999675s". The EngineExited, StartFailed, RestartFailed and health-restart Events now say "10s" and "1m20s"; the agent log keeps the exact figure. Signed-off-by: Christopher Maher <chris@mahercode.io>
Defilan
marked this pull request as ready for review
November 13, 2026 09:46
1 task done
Defilan
enabled auto-merge
November 14, 2026 11:05
Defilan
disabled auto-merge
November 14, 2026 11:06
Open
6 of 7 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Two related metal agent fixes, one commit each, plus a third commit that keeps engine log content out of every Event they add. Three follow-ups came on 2026-11-13: a test (
7909d4e6b), a comment and doc fix (339e31be8), and rounding the delay in Events (734c6380c), found in the live test below.Rebased 2026-11-13 onto main
e012e2e00, after #2529 (RuntimeNotAvailable), #2531 and #2462 (adopting a running engine that misses its probe as unhealthy) merged.RuntimeNotAvailablein the same places this PR addsStartFailed: the reason constants andMetalAgentRefusalReasons, the Events list in the macOS README, and the controller test entries. Both sides are kept.RuntimeNotAvailablerefusal returns at the executor lookup, before the start.StartFailedis written only after a start fails, so a refusal is never reported or retried asStartFailed.7909d4e6bextendsTestEngineExit_AdoptedEnginewith an engine adopted unhealthy. When it exits, its restart is queued at once and oneEngineExitedEvent is emitted. The test fails if the exit watch skips unhealthy engines. The health monitor and the exit watch cannot both restart one engine:stopForRestartchecks and removes the process in one critical section.MetalAgentRefusalReasonsa list of refusals only, whichStartFailedcontradicts. Those comments, plus four nits from the same review, are fixed in339e31be8:markExited's comment now states the 1 s window a late probe really has;StartFailed;summarizeForEventis what keeps log content out of Events;afterSpawnstops the child.A failed start is reported and retried (#2404). A start that fails for a reason other than a refusal or a transient API read (the engine exits while loading, the download fails on a full disk or a bad status, the engine never answers its startup health check, the engine record cannot be written) now:
status.schedulingStatus: StartFailed, with the error's first line asschedulingMessage, so the InferenceService stops saying "Waiting for the host metal-agent to fetch the model and register Endpoints";StartFailedWarning Event with the error's first line, the retry delay and the path of the engine log on the Mac (<log>and<log>.1). A failed engine-record write keeps its existingEngineRecordFailedreason;RestartFailedin the same shape. The status is cleared once an engine runs.An engine exit is acted on at once (#2405). When an engine the agent is serving exits:
EngineExitedWarning Event carries the exit status and the path of the crashed run's log;<runtime>-<namespace>-<name>.log.1instead of truncating it.The probe threshold is unchanged for an engine that is alive but not answering. Engines adopted from a previous agent process are covered too on macOS (see How).
No engine log content in Events. None of these Events, and not the scheduling message, carries engine log content: only the error's first line (cut at the first newline and at 200 bytes, with any attached log tail removed), the exit status, the retry delay and where the log is. The last lines of the engine log go to the agent's own log.
Docs: the agent Events list in
deployment/macos/README.mdnamesStartFailedandEngineExited, says what each carries, and says why neither carries engine log content.docs/site/guides/macos-metal.mdis not touched (another change restructures it).Why
Fixes #2404
Fixes #2405
Before this change a failed start was log-only: no Event, no status, no retry, and the InferenceService kept claiming the agent was fetching the model until something else bumped its resourceVersion or the agent restarted (
refusal.gosaid such failures are not requeued). An engine that exited kept its endpoint for up to 30s, was restarted only after about 90s, left no exit status anywhere, and its restart truncated the log that explained the crash.How
This changes metal agent behavior: failed starts are now retried on a backoff and reported, engine exits trigger an immediate withdrawal and restart, and the previous engine log is kept as
.log.1. Engine log content still never reaches an Event.Failed starts.
handleStartProcessErrorsends every non-refusal, non-transient StartProcess failure to the newfailStart(pkg/agent/start_failure.go). It writes the status on a fresh copy of the InferenceService, schedules a retry on the service's existingrestartBackoff(the delay starts at 10s), emits the Event, and logs the full error, tail included, to the agent log.StartFailedis added toapi/v1alpha1.MetalAgentRefusalReasons, so the controller'sdeterminePhasekeeps it instead of overwriting it withWaitingForMetalAgent, and the agent's refusal-reason scan test and the controller's table test both cover it. It is not a refusal: it is not re-checked on the refusal backoff (5s to 1 min), because a retry may download or load the whole model again.runRestartmarks its own attempt (restartBackoff.attempting), so a failed retry writes the status but is reported once, asRestartFailed, and rescheduled once. Memory admission already clears the status before a start;clearStartFailurecovers memory-check-mode=warn, where admission can end without clearing it.One consequence to know about: while a failed start's retry is pending, a watch event for the same spec (a label or annotation edit, or the agent's own status write) is left to the retry, exactly as
deferToPendingRestartalready does for a health restart. Before, such an edit was the only thing that retried a failed start at all; now it waits for the backoff (at most 5 minutes), and a spec change still starts at once.TestEnsureProcess_StartFailureWithdrawsThenRecoversrelied on the immediate re-delivery and now recovers through the retry;TestEnsureProcess_PlainStartFailureNotRefusedrequired no Event and an empty status (the #2404 bug) and now requiresStartFailedwithout a refusal record.Engine log content stays out of Events. Events are readable by anyone who can read the namespace and are commonly exported to central logging, and an engine log can carry request text (an engine run with verbose logging, through
extraArgs, logs prompts). SosummarizeForEvent(pkg/agent/child_exit.go) builds what an Event, or the scheduling message, may say about a start error: the first line, cut at 200 bytes, with any engine log tail removed.withLogTailnow returns a typed error so the tail can be dropped exactly, and the first-line cut also covers errors that embed other multi-line text. Each Event names the log on the Mac instead (<log>for the failed run,<log>.1for the run before it, or forEngineExitedthe crashed run's log and where the restart moves it) and says its last lines are in the agent log. The agent log keeps the full tail:failStartandrunRestartlog the whole error, and the exit handler logs the tail it reads before the restart rotates the log.runRestart's comment is back to saying the tail belongs in the agent log, not in an Event, with the reason. The first two commits put a bounded tail in these Events; the third removes it, so a squash merge never ships it.Engine exits. The four executors that spawn one engine per service (llama-server, mlx-server, vllm-swift, tensorfold) hand the engine's reaper and log path to its
ManagedProcess. AfterreconcileProcessstores the process,watchEngineExit(pkg/agent/engine_exit.go) waits on the reaper. On an exit it acts only if the agent still tracks that process: every stop the agent makes itself (delete, scale to zero, spec change, eviction, health restart) removes the process from the table before stopping it, so those exits are ignored. It reads the log tail first (the restart rotates the log) for the agent log, marks the engine unhealthy, withdraws the endpoint, and then reuses the health restart's stop-and-schedule body, whichrestartUnhealthynow shares asstopForRestart. The restart therefore goes through the same backoff, start guard, delete and scale-to-zero cancellation, and engine-record cleanup; stopping an exited child goes through its reaper and signals nothing, so a reused PID is never hit. If a start holds the service's key when the exit arrives, the handler retries every second while the engine is still tracked, and withdraws again before each retry, because that start may have registered the endpoint Ready after the first withdrawal. The heartbeat already withdraws instead of registering an unhealthy engine.Adopted engines. An engine adopted from a previous agent process (#2079) is not this process's child, so it has no reaper. On macOS,
Startsets up a kqueueEVFILT_PROC/NOTE_EXITwatch for each adopted engine (pkg/agent/procexit_darwin.go) after the registry is in its mode, then re-proves the engine's identity (its start token), so a PID reused between adoption and the watch is never treated as the engine; an engine already gone at either step is handled as exited. The kernel gives no exit status for a non-child, and the Event says so; it names the log path from the engine's record. The group stop of an adopted engine that is already gone reports an unclean stop, so its restart waits the existing 10s release floor rather than starting at once. On other platformswatchProcessExitis a stub and the probe stays the detector (the agent ships for macOS only). The shared oMLX daemon and Ollama serve many models from one process and are not covered: the probe stays their detector.Log rotation.
openEngineLogfirst renames a regular-file log to<log>.1(one generation, replacing an older.1), then opens a new log withO_NOFOLLOWand mode 0600 as before; the rotated file keeps the mode the agent created it with. A symlink at the log path is not rotated, so the start still fails naming it, and a symlink planted at.1is replaced byrename(2), never followed. Rotation is best effort: if the rename fails, the new run truncates the log as before. It applies to every start of an engine, so.1is always the run before the current one.Consumers.
status.schedulingStatusis read by the controller'sdeterminePhase(now keepsStartFailed) and shown in the InferenceService printer column. The new Event reasons are read by people (kubectl describe). The model store gains one.log.1per engine. No CRD, RBAC, chart or wire format changes.Tests. Each repro was written and run against unmodified
upstream/mainbefore any production edit. #2404 (first versions of the tests inpkg/agent/start_failure_test.go):#2405 (
pkg/agent/engine_exit_test.go; the agent-level test runs the real llama-server executor against a fake engine script the test spawns and SIGKILLs):After review,
TestEvents_NeverCarryEngineLogContent(pkg/agent/engine_log_privacy_test.go) drivesStartFailed,RestartFailedandEngineExitedwith an engine log whose tail carries the stringPROMPT-SENTINEL, and requires that no Event carries it, that each Event names the log, and that the agent log keeps it. Against the first two commits (c75fdc5c7) it failed:TestSummarizeForEventpins the summary: the tail of a wrapped engine-crash error is dropped, a multi-line error keeps its first line, and a long line is cut at 200 bytes. The tests that used to require tail content in an Event now require the log path and forbid the content.Other tests guard what must not change (written with the repros, they pass on main) or cover the fix's own edges (added with it): retry cancellation on delete, scale to zero and spec change; no report or retry for a start cut short by shutdown; the Event tail bound; one Event per failed restart; every spawning executor hands over its reaper and log path; an exit while a start holds the key (including that start's registration); an engine the agent stops itself is not a crash; a symlinked log is still refused; and, on darwin, the kqueue watch on a non-child, on a gone PID, after its context ends, and an adopted engine's exit end to end with the real process inspector.
Falsification: each piece of the fix was reverted on its own and the tests guarding it failed (the first twelve rows were run before the third commit, against the tests then; the Event-content assertions in those rows were replaced by the log-path assertions above):
failStartcall inhandleStartProcessErrorFailedStartIsReportedAndRetried(events = [], status "", no retry),PlainStartFailureNotRefusedFailedStartIsReportedAndRetried,FailedRestartSaysStartFailedattemptingmark inrunRestartFailedRestartSaysStartFailed(duplicate StartFailed Event),FailedStartIsReportedAndRetried(backoff skipped a step)clearStartFailurecallFailedStartStatusClearedWithoutAdmissionStartFailedinMetalAgentRefusalReasonsTestMetalAgentRefusalReasons_CoversEveryAgentRefusalReason, controllerdeterminePhasetable entrywatchEngineExitcallWithdrawsAndRestartsAtOnce(Ready 3s after exit, no restart, no Event)rotateEngineLogcallKeepsOnePreviousGeneration,WithdrawsAndRestartsAtOnce(no.log.1)SymlinkedLogStillRefusedWaitsForAStartInFlightWaitsForAStartInFlight(endpoint left Ready)HandOverReaperAndLog/vllm-swiftTestEngineExit_AdoptedEnginesummarizeForEventTestSummarizeForEvent(multi-line error leaks its second line)summarizeForEventTestSummarizeForEvent(long line kept whole)Risks.
Live test on an M6 Mac (2026-11-13):
containercluster running the released 0.10.17 operator from Helm. The agent was built from339e31be8, then734c6380c.kill -9:EngineExitedEvent was emitted: "exit status unknown: the engine was adopted from a previous agent process". The crashed run's log was kept as.log.1.signal: killed. A second kill a few seconds after its restart backed off to 20 s.StartFailedEvent, thenRestartFailedat 10, 20, 40, 80 and 160 s. No Event carried log content.schedulingStatusreadWaitingForMetalAgent, as expected, since that controller predatesStartFailed. With this branch's controller run in its place, it readStartFailedwith the error's first line and kept it across reconciles.734c6380crounds them to whole seconds, with a test, and the live run then showed "restarting in 10s".AI assistance
Assisted-by: Claude Opus 5.5 (claude-opus-5-5, Anthropic, via Claude Code; roles: investigation, implementation, tests, documentation, PR description, and the 2026-11-13 rebase with its conflict resolution, test and review fixes, plus the live test on an M6 Mac)
Assisted-by: qwen38-flash-next (unsloth/Qwen3.8-Flash-Next-GGUF UD-Q4_K_XL, self-hosted with LLMKube on AMD Strix Halo; roles: read-only review of the rebased branch. Checked the #2529 and #2462 interactions and flagged the stale refusal-list comments and four nits, fixed in 339e31b)
Checklist
make testpasses locally. On the rebased head339e31be8,go test ./pkg/... ./api/... ./cmd/... -count=1passes, and the fullinternal/controllerenvtest suite passes (198 s), all on darwin.go buildandgo vetpass for darwin and linux. Before the rebase, only./pkg/agent/... ./cmd/metal-agent/...and the focused controller spec "should keep an agent refusal" (10 of 1222 specs) were run.make lintpasses locally: 0 issues on the rebased head339e31be8and on734c6380c, which changes onlypkg/agent;go test ./pkg/agent/...passes on it too. Before the rebase, ran./bin/golangci-lint runon./bin/golangci-lint runon./pkg/agent/... ./api/... ./cmd/metal-agent/...under darwin andGOOS=linux: 0 issues each,./internal/controller/...likewise before the third commit, which does not touch it; plus-c .golangci-deadcode.ymlon./pkg/agent/...andgo run ./cmd/check-copyright)git commit -s) per DCO