feat(admin): distinct ACK outcomes + opt-in auto-retry (#4492, #4487) - #4502
Conversation
#4492 splits `rejected` and `timed_out` out of `succeeded`. Both previously settled as `succeeded` with the real outcome buried in `result.ack`, so a caller reading only `status` could not tell "the node confirmed it", "the node refused it" and "we never heard back" apart. #4487 makes that distinction load-bearing rather than cosmetic — a retry loop must not resend a command the node explicitly rejected. `result.ack` is still populated exactly as before, so existing consumers (including the frontend's favorite-ACK handling) keep working untouched. #4487 adds ACK-driven auto-retry: only `timed_out` is retried. A rejection reached the node and was refused — resending re-asks a question already answered — and a `failed` operation never reached the radio. This needs no per-command verification logic, so it applies uniformly to every command kind including ones with no readable state. Attempts come from a per-request `retryAttempts` override, else the `adminRetryAttempts` setting, else 1 — clamped 1..10. The default is deliberately 1, i.e. exactly the pre-#4487 behaviour: every extra attempt is real airtime on a shared band, which is not a cost to impose on every operator by default. Backoff is linear (5s/10s/15s) because the ACK window is already 30s and an exponential curve would push a third attempt minutes out. Two things the change surfaced: - `followAdminOperation` only recognised `succeeded`/`failed` as terminal, so the new states would have polled a settled operation until the 120s timeout. - The same duplicated-terminal-list trap sat in adminRoutes.asyncOperations.test.ts's `waitForSettled` helper, which spun its whole budget once a rejection stopped settling as `succeeded`. It now calls the service's `isTerminal()` so it cannot drift again. `resolveAdminRetryAttempts` is awaited in the request path before the 202, so a settings-read failure is caught and falls back to a single attempt rather than failing the send. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
|
Claude finished @Yeraze's task —— View job PR Review: feat(admin): distinct ACK outcomes + opt-in auto-retry (#4492, #4487)
|

Closes #4492, closes #4487.
They land together because #4492 is a prerequisite: a retry loop must not resend a command the node explicitly rejected, and today
rejectedandtimed_outare indistinguishable fromsucceededonstatusalone.#4492 — distinct terminal states
rejectedandtimed_outsplit out ofsucceeded. Both previously settled assucceededwith the real outcome buried inresult.ack, so a caller reading onlystatuscould not tell "the node confirmed it", "the node refused it" and "we never heard back" apart — even though those demand different treatment.statussucceededrejectedtimed_outfailedresult.ackis still populated exactly as before, so existing consumers — including the frontend's favorite-ACK handling — keep working untouched.#4487 — ACK-driven auto-retry
Only
timed_outis retried. A rejection reached the node and was refused, so resending re-asks a question already answered; afailedoperation never reached the radio. This needs no per-command verification logic, so it applies uniformly to every command kind — including ones with no readable state, like reboot.Attempts resolve from a per-request
retryAttemptsoverride → theadminRetryAttemptssetting → 1, clamped 1–10.The default is deliberately 1, i.e. exactly the pre-#4487 behaviour. Every extra attempt is real airtime on a shared band, and silently multiplying every operator's radio traffic isn't a cost to impose by default — retrying is opt-in. Backoff is linear (5s/10s/15s) rather than exponential, because the ACK window is already 30s and doubling would push a third attempt minutes out, long past when the operator is still watching.
Two duplicated-terminal-list traps this surfaced
Both were live bugs, not test noise:
followAdminOperationrecognised onlysucceeded/failedas terminal. The new states would have polled an already-settled operation until the 120s timeout.waitForSettledinadminRoutes.asyncOperations.test.tshad the same hardcoded pair, and spun its whole budget once a rejection stopped settling assucceeded. It now calls the service'sisTerminal()so it can't drift again.resolveAdminRetryAttemptsis awaited in the request path before the 202, so a settings-read failure is caught and falls back to a single attempt rather than failing the send.Deliberately not done
AdminCommandsTabstill readsresult.ackrather thanstatus. The issue lists it in scope, but the ack payload is unchanged, so the UI behaves identically — rewriting it would add risk without changing behaviour. Happy to follow up if you'd rather the migration be complete.Test plan
timed_outis retryable.success: true, 13007 passed, 0 failed.npx tsc --noEmitclean;npm run lint:cino FAIL lines.docs/features/admin-commands.mdConfirmation Semantics updated, as the issue asked.Credit to @wilhel1812 for the original status model in #4483.
Generated by Claude Code