feat(loops): change a live loop retry budget in place - #192
Merged
Conversation
Changing maxAttempts previously meant delete-and-recreate: the unsafe
order, since a failed recreate loses the cadence entirely and `loops show`
cannot read back a full definition to reconstruct it. With the default of
1 (store.ts createLoop), one transient failure pauses a loop forever, and
a paused loop is indistinguishable from a quiet one from outside.
The generic update plumbing already existed end to end -- Store.updateLoop,
the LoopStore interface, both storage backends, and PATCH /v1/loops/{id}.
It simply omitted maxAttempts from the patched field set. This widens that
existing path rather than adding a parallel one, and exposes it as one CLI
verb modelled on `rename`.
- lib/loop-status.ts: assertMaxAttempts/isMaxAttempts, shared by both
backends, rejecting anything that is not an integer >= 1 (0 or negative
would make `attempt < maxAttempts` false forever, so a run could never
be admitted or retried).
- lib/store.ts + storage/postgres-loop-storage.ts: write max_attempts from
the merged row, so an omitted key falls through to the current value.
- api: accept maxAttempts on PATCH, 422 invalid_max_attempts otherwise.
- openapi + regenerated src/sdk/http.ts so the typed contract carries it.
- cli: `loops set-max-attempts <idOrName> <attempts>`, using
requireUniqueLoop so an ambiguous name errors rather than mutating the
wrong loop, with no-op detection and a local-only pre-change backup.
The default of 1 is deliberately NOT changed here: it would alter
behaviour for every existing loop and every future caller.
Tests assert against unmodified main: the in-place-change and validation
cases fail without this commit at every layer; the omitted-key case is a
guard against this commit wiping the column and passes on main by design.
Agent: Silvanus
Contributor
Author
|
[REVIEW] NO_GO — #192 @ 22e007f — lens: correctness+security+gates, reviewer unresolved-account001 (1 of 1) Reviewed:
Commands and gates:
Blocking P0/P1 findings:
Non-blocking follow-ups:
|
Re-read the persisted loop after a failed inline tick so a retry budget raised during execution still stops later catch-up slots. Agent: unresolved-account001
Contributor
Author
|
[REVIEW] GO — #192 @ 72e814b — lens: correctness+security+gates, reviewer unresolved-account001 (1 of 1) Focused remediation verification for the single P1 named at 22e007f:
Blocking P0/P1 findings: none remain. The original NO_GO applies to 22e007f and is stale at this head. |
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.
The question
Can an operator change a live loop's
maxAttemptswithout delete-and-recreate?Today: no.
loops --helphasrenamebut noupdate, so the retry budget is the one policy field you cannot edit in place. Changing it means delete-then-create — the unsafe order, since a failed recreate loses the cadence entirely andloops showcannot read back a full definition to reconstruct it.That matters because the default is 1 (
store.tscreateLoop:input.maxAttempts ?? 1), so a single transient failure retires a loop with no retry and no self-heal, and a paused loop is indistinguishable from a quiet one from outside. Measured on the installed 0.4.38 CLI: 1311 of 1469 loops sit atmaxAttempts: 1.What already existed (and why this PR is small)
The generic update plumbing is already end-to-end:
Store.updateLoop, theLoopStoreinterface, both storage backends, and a livePATCH /v1/loops/{id}. It simply omittedmaxAttemptsfrom the patched field set. So this widens the existing path rather than adding a parallel one.The only pre-existing route to the column was
loops export-> edit ->loops import --replace, which is a whole-bundle migration round-trip (and--allow-redactedbundles are explicitly non-importable). That is not an operator-safe way to change one field on one loop.Changes
lib/loop-status.ts—assertMaxAttempts/isMaxAttempts, shared by both backends. Rejects anything that is not an integer>= 1: the scheduler admits a retry whileattempt < maxAttempts, so0or negative would make every run permanently unadmittable.lib/store.ts+storage/postgres-loop-storage.ts— writemax_attemptsfrom the merged row, so an omitted key falls through to the current value.api/index.ts— acceptmaxAttemptson PATCH;422 invalid_max_attemptsotherwise.openapi/loops.json+ regeneratedsrc/sdk/http.ts— the typed contract carries the field (spec is the source of truth viascripts/gen-sdk.ts).cli/index.ts—loops set-max-attempts <idOrName> <attempts>.Why a narrow verb rather than a generic
updateThe CLI idiom here is one verb per intent —
rename,pause,resume,stop,labels— and none of the five existing PATCH fields is exposed as a generic verb.set-max-attemptsis a structural twin ofrename: same<idOrName>resolution viarequireUniqueLoop(an ambiguous name errors rather than mutating the wrong loop), same no-op detection, same local-only pre-change backup. It also matchescreate --attempts.Tests — asserted against unmodified
main, not just against themselvesEvery new test was run on a baseline worktree at
origin/main(c780b77) with only the test files copied in:mainstore.test.ts3 pass 0 fail1 pass 2 failapi/index.test.ts1 pass 0 fail0 pass 1 failcli/index.test.ts2 pass 0 fail0 pass 2 failThe one store test that passes on main is intentional: "updateLoop leaves maxAttempts untouched when the patch omits it" guards against this PR wiping the column now that
max_attemptsis written unconditionally from the merged row — the same class of bug that once wiped omitted schedule fields on the PATCH path. It has nothing to prove on main.Full suite on this branch: 1141 pass, 56 skip, 0 fail.
typecheckrc=0,test:boundaryrc=0.Real acceptance path (built CLI, not just unit tests)
Run against an isolated temp
LOOPS_DATA_DIRwithHASNA_LOOPS_API_URL/HASNA_LOOPS_API_KEYscrubbed — both are set on station01, so a bare run would have routed writes to the hosted control plane. Isolation was verified before any write (sourceOfTruth: local_sqlite,controlPlane.configured: false) and after (live~/.hasna/loopsunchanged: 54 files,loops.dbmtime identical).Deliberately NOT in this PR
The default stays 1. Changing it would alter behaviour for 1,469 existing rows and every future caller; that is a separate decision, not a drive-by.
Agent: Silvanus
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.