Stop the autoSync daemon on a permanent mark-synced failure - #138
Stop the autoSync daemon on a permanent mark-synced failure#138grimicorn-agent wants to merge 7 commits into
Conversation
A permanent mark-synced failure (dead token / forbidden account: 401/403) left autoSync on, so the daemon re-PATCHed the same records forever. Mirror the delete path's permanent-vs-transient handling: markRecordSynced returns a new MARK_PERMANENTLY_FAILED outcome, the batch runner aborts on it, and runDefaultSync returns false to stop the daemon. Transient failures keep autoSync alive to retry. Closes #133
Independent code review trailRan the independent reviewer (Opus, fresh context, diff on stdin) across several rounds until the core logic was confirmed correct ("No correctness bug in the abort/stop path"). Summary of what was flagged and done: Correctness / behavior
Code standards
Tests
Deferred (out of scope, flagged as a follow-up in the PR body)
|
…-synced Reconciles #138 (stop autoSync on a permanent mark-synced failure) with main's bulk chunked mark-synced (#128). Permanence is now a run-level MarkAbortReason ('permanent' stops the daemon; 'timeout' and transient-systemic keep it alive) surfaced by markSyncedChunk/markRecordsSynced, instead of a per-record outcome on the removed single-record path.
… into abort - Add 'transient' to MarkAbortReason so a 429/5xx abort reports the run stopped early (records never attempted) instead of reading as a scatter of per-record failures; matching markFailureHeadline branch. - Classify permanence once in markSyncedChunk and fold it into abort so abort and abortReason can't disagree even if isPermanent is widened beyond isSystemic. - Drop the stale 429 @todo (the bulk path already backs off on a systemic 429). - Fix the 25-record test comment (short outcomes array is a mocked abort stand-in).
…bort test - markFailureHeadline now claims 'never attempted' only when a trailing chunk was actually unsent, and 'retried next run' only when a daemon is running — derived from the same (abortReason, autoSyncEnabled) as the returned stop signal, so it can't overclaim on a last-chunk abort or a one-shot sync. - Rename the abortReason:null index test to 'per-chunk (non-systemic)' since a 5xx now maps to 'transient', and add a sibling asserting no overclaim.
- Drop the redundant `abort` boolean from MarkSyncedChunkResult; a non-null
abortReason IS the abort signal, so the type can no longer express a
contradictory { abort: false, abortReason: 'permanent' }. Loop gates on
abortReason !== null.
- Fix the stale doc comment (transient reports 'transient', not null).
- Return the daemon-stop signal from the mark-synced success path too, so the
decision has one source instead of a hardcoded false.
- Fix two stale comments (markSyncedChunk header + MarkSyncedChunkResult doc) that still referenced the removed `abort` field / said transient aborts with null. - Outer sync catch goes through isPermanentApiFailure (the shared seam) instead of reading error.isPermanent directly, matching the mark-synced and delete paths. - Align the transient headline's 're-written next run' hedge with the timeout and generic branches (soft, ungated) instead of a stronger gated 'retried' promise.
- Permanent abort headline notes an unattempted tail (some were never attempted) when the abort truncated the run, matching the transient branch. - Drop 'auth/' from the transient-branch comment (a 401/403 is permanent and is caught by the guard above, so it never reaches the transient branch). - Fix the alignment test's trailing comment (outcomes truncated by an abort, not 'across chunks' — chunking is mocked away in this index-level test).
Agent code review trailIndependent Opus reviewer run in a loop over the merge + fixes. Summary per round (findings condensed; each was verified against Round 1
Round 2 — reviewed a stale diff (uncommitted round-1 fixes weren't in Round 3
Round 4
Round 5 (final)
Skipped (with reason)
lint / typecheck / build / 791 tests green. |
|
Resolved the merge conflicts with main (bulk mark-synced, #128). Conflicts were present in |
What changed and why
A permanent mark-synced failure (a dead token or forbidden account: HTTP 401/403) left
autoSyncon, so the daemon re-attempted the same records every pass forever (issue #133). The delete path already guards this viadeletePermanentlyFailed; this mirrors that pattern for the mark-synced branch.This branch was rebased onto main's bulk mark-synced (#128), which replaced the per-record PATCH with chunked
markRecordsSynced. The permanent-failure handling is now layered onto that bulk path:markSyncedChunkclassifies a failed chunk into a singleMarkAbortReason:'timeout'(hung server),'permanent'(dead token / forbidden account: 401/403),'transient'(a systemic 429/5xx that may be a blip), ornull(a plain per-chunk failure that doesn't abort). Any non-null reason aborts the remaining chunks; only'permanent'also stops the daemon. Permanence is classified via the sharedisPermanentApiFailureguard, so the rule lives in the API seam.markRecordsSyncedsurfaces the aborting chunk's reason up as that singleMarkAbortReasondiscriminant onMarkSyncedResult, replacing the previoustimedOutboolean — one field, so "aborted" and "why" can never disagree.markWrittenRecordsSyncedreturns whether the autoSync daemon should stop — true only whenabortReason === 'permanent'and a daemon was running (autoSyncEnabled) — sorunDefaultSyncreturnsfalse(stops rescheduling) exactly as the delete path does. A timeout or transient failure keepsautoSyncalive to retry.(abortReason, autoSyncEnabled)the returned stop signal uses, so it never claims a daemon stop that didn't happen (a one-shotmarkpost syncnever had one) and only says records were skipped when a trailing chunk was actually unsent. It points the user to the classified reasonmarkRecordsSyncedlogged rather than prescribingmarkpost config(wrong for a 403 plan limit / sign-ups disabled).Implementation decisions
MARK_PERMANENTLY_FAILEDper-record tag would be redundant — the failed records stayMARK_FAILED(pending) and the run carries the'permanent'reason that stops the daemon.autoSyncalive — a lone 5xx can be a blip, so the next pass should retry rather than shutting down.isPermanentApiFailure(api.ts) and reused by the mark-synced, delete, and outer-sync-catch paths.Tests
markRecordsSyncedreportsabortReason: 'permanent'on 401/403 and'transient'on a 503 (both aborting the remaining chunks); direct coverage for theisPermanentApiFailureguard.lint, typecheck, build, and the full suite (791 tests) are green.
No services, env vars, or external setup required. This is a CLI daemon behavior change; verify via
markpost syncwithautoSyncon against a token that 403s.Closes #133
Follow-up suggestions
Sanitize server-derived text in logErrorMessage—markSyncedChunklogs a server-controllederror.messageto stderr vialogErrorMessagewithoutsanitizeForTerminal, unlike every other server-derived print insrc/index.ts; a malicious/compromised server could embed a terminal escape. Best fixed uniformly insidelogErrorMessage(multiple callers). (suggested: P3, effort: S, evidence: src/libs/errors.ts logErrorMessage / src/libs/records.ts markSyncedChunk catch)