Repository navigation
feat: hitl approved actions and deadlines on restate with run-once across restarts - #380
Conversation
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
fuse | 3360d72 | Commit Preview URL Branch Preview URL |
Nov 15 2026, 07:38 AM |
| if res, ok := fm.takeUnstored(id); ok { | ||
| return fm.storeDurableResult(ctx, id, res) | ||
| } | ||
| if in.Exec == nil { |
There was a problem hiding this comment.
HitlRunInProcess skips the start mark for exec and relies on the host's saved answer, which expires after 24 hours by default. If the orchestrator dies after a command finishes but before storing its result, resuming after that window runs the command again under the same key. A command with lasting effects can therefore happen twice.
Persist a replay deadline or keep the host's answer until the intervention is settled.
| fm.hitlSends.Add(1) | ||
| go func() { | ||
| defer fm.hitlSends.Done() | ||
| fm.hitlSendSlots <- struct{}{} | ||
| defer func() { <-fm.hitlSendSlots }() | ||
| ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
There was a problem hiding this comment.
hitlSend limits active requests but creates unlimited goroutines waiting for a slot. During a Restate outage, failed run sends ask the next reconcile to enqueue every unfinished approval again. If that backlog exceeds what eight requests can send between reconciles, duplicate waiters keep accumulating and consume memory.
Use a bounded queue and avoid queuing the same intervention more than once.
| delay := max(time.Until(in.Deadline), 0) | ||
| fm.hitlSend("expire", in.ID, func(ctx context.Context) error { return d.SendHitlExpire(ctx, in.ID, delay) }) |
There was a problem hiding this comment.
scheduleExpiry calculates the remaining time before the send waits for a slot. Restate starts that full delay only when it receives the request, so time spent in the local queue is added to the deadline. A slow or backed-up ingress leaves readers or reconcile to enforce the actual deadline.
Calculate time.Until(in.Deadline) inside the send callback instead.
| case orchestrator.HitlRouteDestroy: | ||
| _, err = restate.Object[restate.Void](ctx, EnvironmentObject, plan.EnvID, "Destroy"). | ||
| Request(orchestrator.DestroyRequest{Reason: orchestrator.DestroyReasonManual}) | ||
| case orchestrator.HitlRouteSnapshot: | ||
| var rec orchestrator.SnapshotRecord | ||
| rec, err = restate.Object[orchestrator.SnapshotRecord](ctx, EnvironmentObject, plan.EnvID, "Snapshot"). | ||
| Request(*plan.Snapshot) | ||
| res.SnapshotID = rec.SnapshotID | ||
| case orchestrator.HitlRouteRotateToken: | ||
| _, err = restate.Object[restate.Void](ctx, EnvironmentObject, plan.EnvID, "RotateToken"). | ||
| Request(restate.Void{}) |
There was a problem hiding this comment.
Approved actions miss lifecycle metrics
These direct calls to Environment skip DestroyVM, CreateSnapshot, and RotateToken, whose wrappers call startOperation. The durable handlers record only step metrics. With Restate enabled, these approved actions disappear from orchestrator_operations_total and orchestrator_operations_duration_seconds, so existing dashboards and failure alerts miss them.
Preserve the operation measurements around these routes without rerunning the actions.
5ed975b to
286068a
Compare
…and fires deadline timers
…s and intervention retry policy
…d per approval, time expiry at send and meter approved lifecycle ops
286068a to
3360d72
Compare
summary
approved hitl actions and intervention deadlines run on restate in modes self and cloud. an approval is a keyed
Intervention/{id}.Runinvocation, so an action interrupted by a restart is carried on instead of settled as "may or may not have run", and every pending intervention's deadline is a durable timer.Interventionvirtual object (keyed by intervention id), handlersRunandExpire. the fleet side is one method per step (HitlRunBegin,HitlRunInProcess,HitlRunFinish,HitlExpire) behind thedurable.Hitlinterface, bound inserver.go;SetDurableHitlwires the client.storeRunResult) itself. destroy, snapshot and token rotation are called onEnvironment/{env}(Destroy,Snapshot,RotateToken) as handler-to-handler calls from the Intervention key (no same-key call, so no deadlock); their steps are not re-run inline, and the run writes what they returned.durable.Runidempotency key (feat: idempotency keys on host-agent mutations so durable steps can retry after a lost response #370), so a retry afterkill -9gets the agent's first answer back instead of running the command again.Intervention/{id}.Expirewith the deadline as its delay. it applieson_timeoutthroughGetIntervention's existing expiry path; anon_timeout: allowapproval is sent toRunlike a human's. an early fire (clock skew) re-sends itself for the rest. the reconcile expiry sweep stays as the backstop.recoverOrphanRunsis not used. the first reconcile sends every approved-without-result action toRunagain (keyedhitl-run:<id>, so one in flight is not started twice), covering a crash between the decision's row write and its send. a failed send sets a flag and the next reconcile resends.orchestrator_hitl_decision_seconds{kind,outcome},orchestrator_hitl_decider_duration_seconds{verdict}(errorwhen the call failed),orchestrator_hitl_run_duration_seconds{action,result}(approval to result).FUSE_RESTATE_MODE=offkeeps the in-process run, thehitlRunsclaim andrecoverOrphanRunsunchanged.design choices
DecideInterventionwrites it there. an invocation waiting on an awakeable or a workflow promise would sleep for the whole deadline (up to 24h), which invariant 4 rules out, and resolving the awakeable has the same crash window between the row write and the restate call as a keyed send. so the decision is a one-way keyed send ofRunonce the row says approve, andon_timeoutis a delayed send ofExpire. no invocation is ever waiting on a human; no migration, no api change.hitlWaitersstays as the in-process wake hint: the handler endpoint runs in the orchestrator process (self and cloud, tunnel or public), sostoreRunResultfrom the durable step wakes it. after a restart the sdk's?wait=re-poll reads the row and is woken when the resumed run stores its result. attaching to the invocation would add nothing the row does not already give.sys_journalin the integration test).INTERVENTIONpolicy's backoff (FUSE_RESTATE_RETRY_INTERVENTION_*), since they only fail on the state store. an endpoint unreachable past the invocation policy behaves like the other ported services (restate's on-max-attempts handling); the resume pass's resend attaches to that invocation rather than starting a second one.deferred
Run:askDeciderstill runs in the gating api request with its 5s timeout and fall-through to a human. it runs before the pending row exists and the agent's request fails if the orchestrator dies mid-call, so there is nothing to resume; journaling it would need the gate itself to become an invocation. latency and errors are now metered.lastCall/quietForfor quiet stalls, the expiry-ahead-of-reap check) stays in reconcile memory. the signal interventions' own deadlines do get the durable timer.local verification
gofmt,go build ./...,go vet ./...,go test ./... -count=1,-raceon durable, orchestrator and metrics,golangci-lintv2.12.2: clean.internal/durablesuite under-race:TestHitlRunSurvivesRestartBeforeRun: run sent, endpoint killed before it starts, restarted endpoint runs it once; a second send (the resume pass) starts nothing.TestHitlRunExecNotRerunAfterRestart: endpoint killed after the agent ran the exec and before the result write; the retry carries the sameIdempotency-Key, the fake agent replays, the command ran once; the journal holds no output.TestHitlExpireSurvivesRestart: deadline timer armed, endpoint killed, fires on the restarted one; an early fire re-arms.TestHitlRunSnapshotCallsEnvironment: an approved snapshot runs throughEnvironment.Snapshotand the run stores its id.on_timeout: allowthrough the timer, metrics.bun run buildpasses;bun run lintfails identically on the base branch (biome config/format of untouched files).not run
hitl-ask,hitl-exec,hitl-access,hitl-takeover,hitl-computer) with mode self and a realkill -9are unrun.FleetManagerbehind restate.Docs:
docs/content/docs/guides/human-in-the-loop.mdx(restart behavior with restate on),docs/content/docs/cloud/monitoring.mdx(hitl metrics),docs/content/docs/cloud/orchestrator-deploy.mdx(INTERVENTIONretry policy). checked and left alone:concepts/reconcile.mdx,cloud/tracing.mdx. no api surface change, so no sdk or openapi changes.Stacked on #379.
Closes #342
The exec replay window must be bounded before merging because a long outage can cause an approved command to run twice.
Summary
Moves approved HITL actions and pending deadlines onto Restate while keeping the in-process path when Restate is off.
Diagram
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Human decision or deadline] --> B[Save decision on intervention] B --> C[Send keyed Intervention.Run] C --> D{Action} D --> E[Exec or other in-process action] D --> F[Environment durable handler] E --> G[Save result and wake waiters] F --> G H[Pending intervention] --> I[Delayed Intervention.Expire] I --> A J[First reconcile after restart] --> CReviews (1) · Last reviewed commit: "docs: hitl approvals across restarts wit..." · Reviewed by Greptile