Quarantine permanently unrestorable workflow deployment records - #292
Merged
Conversation
Covers the accumulation bug: a record that fails restore forever gets no counter, no marker, nothing that ever stops the per-boot warning. Unit tests exercise recordWorkflowDeploymentRestoreFailure, clearWorkflowDeploymentRestoreFailure, and isWorkflowDeploymentRestoreQuarantined directly; the integration test proves the actual behavior a boot cares about: a permanently unrestorable record (address/directory mismatch) stops being attempted after RESTORE_QUARANTINE_THRESHOLD consecutive boots, while a transiently unbuildable inference provider is retried every boot indefinitely and never quarantines.
The boot-time restore loop replays every persisted deployment record serially before hubLink.connect(), so a pile of dead records gates the hub handshake at every boot. The root cause: a record that fails to restore was never marked, so a permanently broken one warned on every boot forever with nothing to stop it. workflow-deployment-record.ts now tracks a restoreFailure marker (kind, attempts, reason, lastAttemptAt) on the record itself, plus a WorkflowRestoreFailure error class the restore path throws to say which kind a failure is. Only two gates are deterministic given solely the record's own persisted bytes -- an address that derives a different slug than its directory, and a closure-derived definition that fails structural validation -- so only those throw "permanent". Everything else (an unbuildable inference provider, a closure-store miss, a spawn-core failure) depends on this boot's environment, not the record's content, so it is treated as "transient" by default and retried every boot for as long as the record exists; it is deliberately never on the same counter as a permanent failure, so it can never trip the quarantine. After RESTORE_QUARANTINE_THRESHOLD (3) consecutive permanent failures, the boot loop stops attempting the record and folds it into one summary line instead of a per-record warning. The record itself is never deleted -- an operator reclaims it by undeploying the address -- extending the existing legacy-address pruning rather than replacing it.
4 tasks
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.
Summary
The boot-time restore loop replays every persisted deployment record serially, before
hubLink.connect()— so a pile of dead records gates the hub handshake at every boot. On the owner's machine that meant 62-63 records replayed serially at every boot, most of them long dead. Root cause: a record that fails to restore is never marked, so a permanently broken one warns on every boot forever and nothing ever removes it.This is scoped to fixing that accumulation at its root — not the restore loop's serial-vs-parallel question, which is PR #288's territory and stays untouched here.
What changed
apps/sidecar/src/workflow-deployment-record.ts: the record now carries an optionalrestoreFailuremarker (kind,attempts,reason,lastAttemptAt), plusWorkflowRestoreFailure(an error class the restore path throws to say which kind a failure is),RESTORE_QUARANTINE_THRESHOLD = 3, and three functions:recordWorkflowDeploymentRestoreFailure,clearWorkflowDeploymentRestoreFailure,isWorkflowDeploymentRestoreQuarantined.apps/sidecar/src/workflow-host-wiring/index.ts: the boot loop skips an already-quarantined record entirely (no attempt, no per-record warning — just a count toward one summary line), tracks a new failure's kind/reason on success or failure, and clears the marker the moment a restore succeeds.The quarantine rule, and why N=3
After 3 consecutive permanent restore failures, a record stops being attempted and folds into one summary line (
Skipped N quarantined workflow deployment record(s)...) instead of a per-record warning every boot. Permanent failures are deterministic — derived only from the record's own immutable closure pin and address, never from runtime environment — so they don't actually need repeated confirmation to be believed. 3 is a small buffer against a misclassification, while still bounding "warn forever" to a handful of boots. The record file itself is never deleted by quarantine — an operator still reclaims it by undeploying the address, same as before.Permanent vs. transient — the part that has to be right
Only two restore-path gates are classified
WorkflowRestoreFailure("permanent", ...), because they're the only two that are deterministic given solely the record's own persisted bytes:agentAddressderives a different slug than its own directory. Corrupt or misplaced; re-deriving the same address on a later boot yields the identical mismatch every time.WorkflowProjectionDefinitionarktype validation, or failsvalidateWorkflowProjection's structural invariants. The closure is an immutable pin, so re-evaluating it later produces the identical broken shape every time.Everything else is treated as transient — retried every boot for as long as the record exists, on a separate counter that can never trip the quarantine, however high it climbs:
assertSourceBuildablefailures (the explicitly-called-out case) — an unbuildable inference provider is this boot's adapter registry, not the record's content. A later boot with the provider registered can succeed with the exact same record, unchanged. This is now wrapped explicitly at its call site to mark it"transient".Errors. The boot loop's default for anything that isn't an explicitWorkflowRestoreFailureis"transient"— the safe default, since misclassifying a recoverable failure as permanent stops future restore attempts for it, which is exactly the failure mode this PR is careful not to introduce.The legacy-address prune (an
ins_-prefixed address the current parser rejects outright) is untouched: it still deletes the record immediately, same as before — that's today's only GC path, extended rather than replaced.Boot log, before vs. after
Before, a pile of dead records produced one
warnline per record, every single boot, forever:After, the first 3 boots look the same (still confirming it's really dead), the 3rd carries an explicit quarantine notice, and every boot after that collapses to one line:
A transiently-unbuildable-provider record keeps warning every boot exactly as before — no change to that behavior, since it never quarantines.
Test plan
apps/sidecar/src/workflow-deployment-record.test.ts— unit tests forrecordWorkflowDeploymentRestoreFailure(counter start/increment/kind-reset),clearWorkflowDeploymentRestoreFailure,isWorkflowDeploymentRestoreQuarantinedapps/sidecar/test/workflow-restore-quarantine.test.ts— integration test through the real router: a permanent failure (address/directory mismatch) quarantines after 3 boots and the attempt counter stops moving; a transient failure (unbuildable provider) is retried every boot for 5 boots straight and never quarantinesapps/sidecartypecheck cleanapps/sidecartests still pass (no regressions)prettier --checkclean on changed files,eslintclean on changed filesorigin/main(no overlap — nothing underapps/sidecar/changed on main since this branch forked)