feat(sync): keep the engine up when the server refuses, and let the reason be read - #792
Merged
Merged
Conversation
…eason be read A remote may answer a write with a status meaning the caller must do something, rather than try again later. The engine treated that as a transport failure: not retryable, so it left the loop, and the process exited. status then said 'engine stopped — run: remote.sh sync start', which invites the one action that cannot work — starting it again produces the same refusal and the same exit, for as long as the server's answer stands (#773). THE REASON WAS NEVER MISSING. event() writes it to the run log with a timestamp, at a known path. What was missing is a place to READ it: status opens a pidfile and the cycle stamp, and nothing an agent consults mentions the log. So this adds one fact beside the cycle stamp, not a new mechanism. BY CLASS, NOT BY NUMBER. A refusal is a 4xx the retry policy does not cover. The one such status in use today is deliberately not named anywhere in the engine — not in the code and not in a comment, which a test enforces. Naming it invites the next reader to special-case it and the one after that to add a sentence about what it means. 5xx stays transport; the retryable 4xx stay retryable, because isRetryable is asked first. THE ENGINE STAYS UP. A refusal is not retryable — asking again does not change a decision — and not a transport failure either. Exiting turns a recoverable condition into a dead process whose status recommends a no-op. And the answer can change out of band: someone pays, a quota resets, an operator fixes a setting. Staying up means the next cycle recovers with nobody typing anything. It backs off to the longest interval and does not advance the failure count, because a refusal is not evidence that the transport is degrading. WHAT IS STORED AND WHAT IS NOT. Stored: status, code, time, and the host from the endpoint the config already holds — verbatim, as the server said them. Not stored: any sentence about what the refusal means or what to do. This engine talks to a remote that may be self-hosted, someone else's, or a service, and a sentence it invents is wrong for some server. Interpretation belongs to whoever operates that server; the host is there so a reader knows who that is. Cleared by the next successful cycle, beside where the success is recorded. A record that outlives its truth is worse than none: status would keep reporting a decision the server has since reversed. THE AGENT'S SURFACE. remote.sh status --json is what /agmsg remote status runs, and the only thing an agent consults about the engine. The whole record is passed through, so a field added by the engine needs no change there; an unreadable record reports as absent rather than guessed at. Tests: 4 in the engine suite (classification by class, the loop not exiting, a success clearing the record, and a non-refusal still ending the loop) and 6 in bats (the wording repeated not interpreted, an unenumerated status surviving, the JSON carrying it verbatim, null rather than a missing key, an unreadable record read as absent, and that the engine names no particular remote — with a positive control on that search). Not here: the auto-start reading the record before starting a team. That lives in scripts/lib/sync-autostart.sh, which does not exist on this branch — it arrives with #774, so those lines land there and are reviewed there.
The clear was the only thing standing between a reversed decision and the operator, and the clear is allowed to fail. runLoop removes the record after a successful cycle, best-effort, and swallows the failure — an unwritable run directory, a permission change, a crash between the two writes. remote.sh then read the file and reported whatever was in it. So a failed removal meant status and the JSON kept reporting a decision the server had since reversed, for ever. My own body and comment claimed the opposite: that a record outliving its truth is worse than none, and that the two facts are written in one place and so can never disagree. They are written in one place and they still can, because one of the two writes may fail. The standard was right and the implementation did not meet it. THE FIX IS ON THE READING SIDE, and that is the stronger of the two. Making the delete certain would mean retries and escalation in a path whose whole rule is that bookkeeping must never cost a cycle that worked. Instead the reader compares the record to the last successful cycle and reports nothing older. Deleting can fail. Comparing cannot. Both readers go through one helper, so the human line and the JSON cannot disagree about whether a refusal still stands. With no cycle stamp there is nothing to compare against and the refusal IS reported: an engine that has refused and never succeeded is the case the record exists for. The comparison is lexicographic on two timestamps written by the same engine in the same format. That is exact here and is not a general date comparison; the comment says so. Also: reading the record for the comparison is quiet. Saying an unreadable record is unreadable put a parse error into the stream a caller reads the JSON from, which turned the 'unreadable reads as absent' case red. Three tests: a refusal older than the last cycle is not reported THOUGH THE FILE REMAINS (the failed-delete case, made deliberate); a newer one still is (without which the check is satisfied by never reporting anything); and with no stamp at all it is reported. Mutation: remove the comparison -> that one case red, the other eight green. Same shape as #778 in registry-lock.sh, on the same night: cleanup done best-effort, its failure swallowed, and what remains read as the current truth. Passed to that seat.
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.
Declared reviewers: 1
Closes #773.
Landing on
integration/remote. Head288545154f84e334460eec9d1e6e934170afceeb.What was wrong
A remote may answer a write with a status meaning "the caller must do something" rather than "try again later". The engine treated that as a transport failure: not retryable, so it left the loop,
main()rejected, and the process exited.statusthen said:which invites the one action that cannot work — starting it again produces the same refusal and the same exit, for as long as the server's answer stands.
The reason was never missing.
event()writes it torun/remote-sync.<team>.logwith a timestamp, at a known path. What was missing is a place to read it from:statusopens a pidfile and the cycle stamp, and nothing an agent consults mentions the log. So this adds one fact beside the cycle stamp, not a new mechanism — the same move #760 made.By class, not by number
A refusal is a 4xx the retry policy does not cover. The one such status in use today is deliberately not named anywhere in the engine — not in the code and not in a comment, and a test enforces that. Naming it invites the next reader to special-case it, and the one after that to add a sentence about what it means.
5xxstays a transport failure; the retryable 4xx stay retryable, becauseisRetryableis asked first.The engine stays up
A refusal is not retryable — asking again does not change a decision — and it is not a transport failure either. Exiting turns a recoverable condition into a dead process whose
statusrecommends a no-op.And the answer can change, out of band: someone pays, a quota resets, an operator fixes a setting. Staying up means the next cycle recovers with nobody typing anything. It backs off to the longest interval and does not advance the failure count — a refusal is not evidence that the transport is degrading, so it must not shorten anything else's backoff.
What is stored, and what is deliberately not
Stored: the status, the code, the time, and the host from the endpoint the config already holds — verbatim, as the server said them.
Not stored: any sentence about what the refusal means or what to do about it. This engine talks to a remote — self-hosted, someone else's, or a service — and it cannot know why a particular one refused. A sentence it invents is wrong for some server. Interpretation belongs to whoever operates that server, and the host is there so a reader knows who that is.
A record that outlives its truth is worse than none, and the engine's delete is not what makes that hold.
The engine clears the record after a successful cycle, best-effort, and swallows the failure — an unwritable run directory, a permission change, a crash between the two writes — for the same reason
recordCycleSuccessdoes: bookkeeping must never cost a cycle that worked. The first version of this PR stopped there, and the reader believed whatever file it found. A failed delete therefore reported a reversed decision for ever (raised in review; my own body claimed the two facts were written in one place and so could never disagree — they are, and they still could, because one of the two writes may fail).So the guarantee is on the reading side:
remote.shcompares the record to the last successful cycle and reports nothing older. Deleting can fail. Comparing cannot. Both readers go through one helper, so the human line and the JSON cannot disagree; with no cycle stamp there is nothing to compare against and the refusal is reported, because an engine that has refused and never succeeded is the case the record exists for.The surface an agent reads
/agmsg remote statusrunsremote.sh status --json, and it is the only thing an agent consults about the engine — so the refusal is there, not only in a line a human reads. The whole record is passed through, so a field the engine adds later needs no change on the reading side. An unreadable record reports as absent rather than guessed at.Tests
Engine (
tests/remote_sync_engine.test.mjs, 4 new): classification by class —400/401/402/403/409/422/451refuse,408/429stay retryable,5xxstays transport, a statusless error is neither; the loop does not exit on a refusal (it comes back for a third cycle, which is how the test can end at all) and records verbatim with the host; a successful cycle clears the record; and a non-retryable error that is not a refusal still ends the loop — the negative control, because staying up for everything would turn a malformed config into an engine that spins forever saying nothing useful.Reading (
tests/test_remote_refusal.bats, 9 new): the server's words repeated and nothing invented (subscri,upgrade,billing,planall refused in the output); a status this protocol never enumerated surviving the trip; the JSON carrying it verbatim;nullrather than a missing key when nothing was refused; an unreadable record read as absent; and that the engine names no particular remote — with a positive control on that search, since three refutes prove nothing if the search cannot find anything in that file.Plus three on staleness: a refusal older than the last cycle is not reported though the file remains — the failed-delete case, made deliberate; a newer one still is, without which the check would be satisfied by never reporting anything; and with no stamp at all it is reported.
That check on the engine's vocabulary caught its own author: the only occurrence of the number was in my comment explaining that the number is not written anywhere. The comment now says the rule applies to itself, which is enforced.
Measurements, at this head
Mutation: remove the currency comparison — that one case red, the other eight green, so the filter is not simply hiding everything.
Rebased onto
origin/integration/remotebefore opening this, while no verdict existed to invalidate — cheaper than carrying a stale base into review. Everything above was re-run on the new base; the green from the old one was not reused.Re-measured immediately before landing.
Three seats are editing this file
scripts/internal/remote-sync.mjsis also touched by #779 and by #781/#782. Ranges were exchanged and measured, not assumed: this branch adds anode:fs/promisesimport, a block afterrecordCycleSuccess, and three hunks insiderunLoop. A grep of this diff forrunDriver,child.on,readBoundedAuthorityFile,readRetainedCheckpointFileandTRUST_DIRreturns 0.No text conflict is expected. Drift is measured per file, so whichever of the three lands last will see non-empty drift here and needs a rebase and a fresh verdict; a verdict from an older head is not carried forward.
The same shape landed twice tonight
#778inscripts/lib/registry-lock.shis this defect in another file: cleanup done best-effort, its failure swallowed, and whatever remains read as the current truth. A lock that outlives its holder and a refusal that outlives its reversal are the same mistake.The answer here — make the reader refuse to lie, rather than make the cleanup certain — was passed to that seat. If it turns up a third time it is a checker, and a checker only counts once it is wired into something that runs.
Not in this PR
The auto-start reading this record before starting a team. That belongs in
scripts/lib/sync-autostart.sh, which does not exist on this branch — it arrives with #774 (PR #775), so those lines land and are reviewed there.The ordering constraint runs the other way: #775 must not land before this. An engine that exits on a refusal, plus an auto-start that does not read refusals, is a restart loop; a refusal recorded with no reader yet is inert.