fix(modules): the retry rescue names its own stage, and /system hears about it #45
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/issue-30-retry-rescue-stage"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #30.
The two contradictions
schedule's catch inpackages/server/src/modules/external-retry.ts:clone, because that is the only shape this loop takes. A throw out ofhost.loadExternalreached the operator as a clone problem.host.failed. Every escape this catch can see lands afterattemptOncealready cleared the pre-retry record — since #27 moved themkdirs insideprepareExternalModule's try, nothing above that clear can reject — so the host held nothing, and/systemreported the repo asunknown, the status that otherwise means the loader skipped the entry, while the bell called the module permanently stopped./systemis the page that alert sends the operator to.The taxonomy call
The issue flags that no
ModuleFailureStagemeans "the retry machinery itself broke". It gets one:retry.The alternative the issue offers — leave the stage out of the rescue message — does not actually settle it.
ModuleFailure.stageis not optional and/systemrenders it, so the record would still assertcloneor some other lie; only the bell would go quiet about it. The point of the fix is that the two agree, so the honest answer is a stage that says what happened.It slots in as its own thing rather than reusing
backend: nothing in the retry path is supposed to reject at all —prepareExternalModulecarries its failure in the outcome,load()disables the module — so this names an escape from both, and the realistic cause is a full or read-only/datarather than anything about the module.isRetryableFailuregates onstage === "clone", so aretryfailure is never itself retried. That is right: nothing about a full/dataresolves on a backoff.The fix
The record makes the host say what the bell says. The clear is defence against an escape from above
attemptOnce's own clear — unreachable today, cheap to keep.Still meant to be unreachable overall: the two
Deno.mkdircalls moved insideprepareExternalModule's try in #27. This only makes it honest when it fires.Test
One added to
packages/server/tests/external_modules_test.ts. Nothing in the retry path is supposed to reject, so reaching the catch means making something in it throw —host.loadExternalis stubbed to reject withno space left, which is the shape of the real cause. It asserts both halves:retry: no space left. Stopped after 1 attempt …, notclone:;host.failedholds exactly one record, stageretry, with that URL and message — where before,attemptOncehad already cleared the pre-retry record, so/systemshowed the module as neither running nor failed while the bell called it permanently stopped.Verified it fails with the source change stashed.
Elsewhere
Adding a union member breaks nothing:
system.tspassesModuleFailureStagestraight through into the report and the shell renders it as a string — there is no exhaustive switch on it anywhere.Gates:
deno task check,deno lint packages/server/,deno fmt --check,packages/server/tests/(111 passed).CLAUDE.md's stage list gains it, with why it is the odd one.Re-review, and I checked the claims rather than reading them.
Ran on the PR head merged with current
main(2844b43) — the branch sits onfb29e2c, and main has since gainedctx.storage, which toucheshost.ts. Merges clean, no semantic conflict:deno fmt --check,deno lint(repo-wide, as CI runs it),deno task check: all clean.packages/server/tests/: 120 passed (the 111 you quote, plus the 9 storage tests main added).external-retry.tsto base and re-ran the new test: it fails, with exactlyclone: no space left. Stopped after 1 attempt — …. The claim holds.stage: "retry"change fails the message assertion; dropping onlyrecordFailurefailshost.failed.length. Both halves are independently covered.The reasoning, not just the tests:
system.tspasses it into the report,SystemPage.sveltetypes itstring | nulland renders it. The union member is as free as you say.isRetryableFailuregates onclone, and the seeding loop runs once at startup, so aretryrecord can never re-enter the loop or be re-alerted bymain.ts(which alerts over a copy, before the first backoff elapses).load()wraps everything in one try, including theonLoadfan-out, and each listener has its own catch. So a module cannot end up inloadedand recordedretry— the "still meant to be unreachable" framing survives.ref.url === failure.configuredAsis guaranteed byisRetryableFailureplus thefindabove, so clear-then-record targets the same identityfailureKeyderives the alert key from.untildeterministic. Right order.Two prose problems, and they are the same problem twice — a sentence asserting a state the code cannot be in. Inline. Code stays as written; fix the words before merge.
Aside, not this PR:
seedReporunsgit commitwithout-c commit.gpgsign=false, so on a checkout with global commit signing the three repo-seeding tests die withgpg failed to sign the databefore asserting anything — I had to pointGIT_CONFIG_GLOBALat a stub config to run them at all. Two of the three predate this PR; the new one inherits it. Worth a follow-up.@ -283,0 +282,4 @@(which carries its failure in the outcome) and `load()` (which disables themodule) — and the rescue records it on the host too, or the bell would call amodule permanently stopped that `/system` does not list at all. Container buildsstamp `OPSDECK_VERSION`/`OPSDECK_COMMIT` via Docker build args; a checkout/systemdoes list it.moduleReportshas a third status for exactly this shape: a configured entry with neither a load nor a failure comes out asstatus: "unknown", whose own comment reads "seeing it means the loader itself skipped an entry". So the pre-fix symptom was not a missing row — it was a row blaming the loader, which is a different wrong answer and the one an operator would actually have been staring at. The test comment gets this right ("neither running nor failed"); this sentence overstates it into an absence.@ -117,0 +120,4 @@// reported as a clone problem. The escape can come from anywhere in// the path, and naming where it came from is the point of a stage.const rescued: ModuleFailure = {name: failure.name,Nit, no change requested: when the escape comes out of
loadExternalthe manifest name is known (outcome.module.name) and this reports the repo slug instead, so the title staysModule "<slug>" is not runningfor a module whose manifest read fine. Harmless —failureKeyis slug-based either way, and the row the operator was already looking at said the slug too — and the rescue has no access to the outcome without restructuringattemptOnce.@ -117,0 +132,4 @@// at all, while the bell says the module is permanently stopped —// and `/system` is the page that alert sends the operator to. Clear// then record, so either state ends as this one record.host.clearExternalFailure(ref.url);"either the stale pre-retry record or nothing at all" — only the second is reachable.
prepareExternalModulecannot reject beforeattemptOnce's own clear on line 162: since #27 moved themkdirs inside its try, everything that can fail is in there, andslugify/log.childabove the try are pure string work. Every escape this catch can actually see is therefore post-clear, so the state is always "nothing at all".Verified rather than reasoned: delete this
clearExternalFailure(ref.url)line and all 10 tests in the file still pass. Keep the call — belt and braces is the whole spirit of this handler — but the comment asserts a host state that cannot occur, which is the same class of lie the PR exists to remove. Either name it as defensive against an escape that would have to come from above line 162, or drop the two-states framing.