suspend

package
v0.9.21 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 21, 2026 License: Apache-2.0 Imports: 10 Imported by: 0

Documentation

Overview

Package suspend orchestrates the question/answer lifecycle and halt/wake loop that sits on top of the checkpoint primitive. It turns an executor's checkpoint.Suspension (an error value carrying a serializable Envelope) into a durable pause: the envelope is parked in a checkpoint.Store, a Question for the friend — the answering party, a human operator or another agent — is posted to a QuestionStore under a fresh nonce, and the reconciler is asked to come back later via a workqueue requeue — no live process is held across the wait. The wait itself is always bounded: an envelope parked without a deadline is stamped with the Coordinator's DefaultParkDeadline (14 days unless configured), and Wake's dead-checkpoint sweep retires any park whose deadline has lapsed unanswered.

The Coordinator is the single entry point. Suspend halts a run; Wake is the tri-state re-entry probe a reconciler calls at the top of every reconcile:

WakeFresh  — nothing parked (or the checkpoint expired/drifted): run from scratch.
WakeRearm  — parked but no answer yet (or a duplicate waker won the claim):
             return a cheap RequeueAfter and touch nothing.
WakeResume — parked and answered: the envelope has been claimed (Store CAS
             delete) and the question consumed; resume with the raw answer
             (the executor's Resume owns framing).

checkpoint stays a pure primitive: it knows nothing about questions, nonces, or the workqueue. All of that lives here.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Answer

type Answer struct {
	// QuestionID is the nonce of the Question this answers.
	QuestionID string `json:"question_id"`
	// Text is the raw friend answer. It flows raw all the way to the executor's
	// Resume, which owns framing (checkpoint.FramedAnswers) before the answer
	// reaches a provider payload — no layer in between frames or caps it.
	Text string `json:"text"`
	// AnsweredAt is when the friend replied.
	AnsweredAt time.Time `json:"answered_at"`
}

Answer is the friend's reply to a Question. QuestionID MUST equal the ID of the Question it answers so the store can reject answers bound to a stale pause.

type Coordinator

type Coordinator struct {
	// Store is the durable home for suspended envelopes.
	Store checkpoint.Store
	// Questions is the friend transport for the pending question/answer.
	Questions QuestionStore
	// WakeInterval is the base requeue delay between wake probes. Must be > 0.
	WakeInterval time.Duration

	// Jitter, when > 0, spreads wake requeues across [WakeInterval,
	// WakeInterval+Jitter) so keys that suspended together don't stampede back
	// at once. Optional; zero means no jitter.
	Jitter time.Duration

	// DefaultParkDeadline bounds how long a park may wait for an answer when the
	// suspension's envelope carries no Deadline of its own: Suspend stamps
	// Envelope.Deadline = now + DefaultParkDeadline before persisting, so
	// Wake's dead-checkpoint sweep retires every park once its answer SLA
	// lapses — a suspension can never wait forever. An explicit caller-set
	// Deadline is preserved untouched. Optional; zero means the 14-day default
	// (aligned with the answer SLA and checkpoint-bucket TTL); negative values
	// are rejected by Suspend.
	DefaultParkDeadline time.Duration

	// WithBackup, if set, is invoked after the envelope is durably Saved but
	// before the question is posted, giving callers a hook to mirror the
	// checkpoint elsewhere (e.g. a secondary store or an audit log). A non-nil
	// error aborts the suspension.
	WithBackup func(ctx context.Context, key string, env *checkpoint.Envelope) error

	// ValidateEnvelope, if set, is consulted on Wake against a parked envelope.
	// Returning an error that Is(err, checkpoint.ErrConfigDrift) causes Wake to
	// treat the checkpoint as unusable: it is deleted and WakeFresh is returned.
	ValidateEnvelope func(ctx context.Context, env *checkpoint.Envelope) error
	// contains filtered or unexported fields
}

Coordinator drives the halt/wake lifecycle. It parks suspended envelopes in Store, posts and reaps friend questions through Questions, and expresses the wait as a workqueue requeue paced by WakeInterval.

Construct it with New so WakeInterval is validated > 0; the exported fields remain settable for callers that want to wire optional hooks, but Suspend and Wake defensively re-check WakeInterval so a zero value can never turn a pause into a hot-spin.

Example

ExampleCoordinator demonstrates the full halt/wake lifecycle: Suspend parks an executor's checkpoint.Suspension and posts the human question, then Wake walks the tri-state re-entry — rearm while unanswered, resume exactly once after the human answers, fresh thereafter.

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"time"

	"chainguard.dev/driftlessaf/agents/checkpoint"
	"chainguard.dev/driftlessaf/agents/checkpoint/memstore"
	"chainguard.dev/driftlessaf/agents/suspend"
	"chainguard.dev/driftlessaf/agents/suspend/memquestions"
	"chainguard.dev/driftlessaf/workqueue"
)

func main() {
	ctx := context.Background()
	store, questions := memstore.New(), memquestions.New()

	c, err := suspend.New(store, questions, time.Minute)
	if err != nil {
		fmt.Println("error:", err)
		return
	}

	// An executor suspended on a held-out ask-a-friend call and handed the
	// Suspension up as an ordinary error; the reconciler parks it here.
	err = c.Suspend(ctx, "org/repo#42", &checkpoint.Suspension{
		Envelope: checkpoint.Envelope{
			Version:        checkpoint.EnvelopeVersion,
			Provider:       checkpoint.ProviderAnthropic,
			Model:          "claude-fable-5",
			ConfigDigest:   "sha256:cfg",
			RunID:          "run-1",
			Turn:           3,
			RemainingTurns: 8,
			PendingToolCalls: []checkpoint.PendingToolCall{{
				ID:   "toolu_01ABC",
				Name: "ask_a_friend",
			}},
			ProviderState: json.RawMessage(`{"messages":[]}`),
		},
		Question: "Should I force-push?",
	})
	delay, requeued := workqueue.GetRequeueDelay(err)
	fmt.Println("parked, wake in:", requeued, delay)

	// Unanswered wake: rearm and mutate nothing.
	d, _, err := c.Wake(ctx, "org/repo#42")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println("unanswered:", d)

	// A human answers through the question store's transport.
	q, ok := questions.Provide(ctx, "org/repo#42", "Yes, go ahead.")
	fmt.Println("question was:", ok, q.Prompt)

	// Answered wake: the envelope is claimed (CAS delete) and the question
	// consumed, exactly once. The caller resumes from the WakeResult; the
	// executor's Resume owns framing the raw answer.
	d, res, err := c.Wake(ctx, "org/repo#42")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println("answered:", d, res.Answer.Text)

	// The claim removed the checkpoint, so the next wake starts from scratch.
	d, _, err = c.Wake(ctx, "org/repo#42")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println("after claim:", d)
}
Output:
parked, wake in: true 1m0s
unanswered: WakeRearm
question was: true Should I force-push?
answered: WakeResume Yes, go ahead.
after claim: WakeFresh

func New

func New(store checkpoint.Store, questions QuestionStore, wakeInterval time.Duration) (*Coordinator, error)

New returns a Coordinator with WakeInterval validated > 0. Optional hooks (WithBackup, ValidateEnvelope, Jitter) are set on the returned struct by the caller. Answers are carried raw end-to-end: the executor's Resume owns framing (checkpoint.FramedAnswers with checkpoint.DefaultAnswerMaxBytes), so the Coordinator imposes no answer-size policy of its own.

func (*Coordinator) Suspend

func (c *Coordinator) Suspend(ctx context.Context, key string, s *checkpoint.Suspension) error

Suspend halts a run: it durably parks s.Envelope under key (stamping a deadline of now+DefaultParkDeadline when the envelope carries none, so every park is bounded), runs the optional backup hook, posts a fresh-nonce Question, and returns a workqueue.RequeueAfterWithJitter so the reconciler comes back later to Wake.

The returned requeue error is the success path — it is the control-flow signal a reconciler propagates to pause the key, not a failure. A genuine failure (Save/backup/Ask error) is returned as an ordinary error instead.

func (*Coordinator) Wake

Wake is the tri-state re-entry probe. See WakeDecision for the three outcomes. On WakeResume it has already claimed the envelope (Store CAS delete) and consumed the question; the caller owns resuming from the returned WakeResult. WakeResume is never accompanied by a non-nil error: the CAS claim is the commit point, so a failure to consume the question afterwards is logged rather than returned — the claimed envelope is already gone, and an error here would steer err-first callers into dropping the answered run. On WakeFresh caused by a dead checkpoint it has already deleted the stale envelope and best-effort consumed its orphaned question. WakeRearm mutates nothing.

type Question

type Question struct {
	// ID is the pause nonce. Fresh per Suspend; never reused.
	ID string `json:"id"`
	// Key is the reconciler/workqueue key the paused run belongs to.
	Key string `json:"key"`
	// RunID disambiguates successive runs of the same key (copied from the
	// suspended envelope, diagnostics only).
	RunID string `json:"run_id,omitempty"`
	// Prompt is the human-readable question text.
	Prompt string `json:"prompt"`
	// AskedAt is when the question was posted.
	AskedAt time.Time `json:"asked_at"`
}

Question is the prompt posted to the friend — the answering party, a human operator or another agent — when a run suspends. Its ID is a per-pause nonce: it binds an incoming Answer to one specific pause instance so a stale answer left over from an earlier pause of the same key can never be mistaken for the current one. The Coordinator mints a fresh ID on every Suspend; a QuestionStore MUST preserve it verbatim and only surface an Answer whose QuestionID matches the currently-pending Question.

type QuestionStore

type QuestionStore interface {
	// Ask posts q as the pending question for key, replacing any previous
	// pending question (and its answer) for that key. q.ID is the fresh nonce.
	Ask(ctx context.Context, key string, q Question) error

	// Pending returns the currently pending Question for key; the bool is
	// false (with a zero Question and nil error) when none is pending. Wake
	// reads it before sweeping a dead checkpoint so the orphaned question can
	// be consumed by its nonce.
	Pending(ctx context.Context, key string) (Question, bool, error)

	// Answer returns the friend reply for key's pending question. The bool is
	// false (with a zero Answer and nil error) when the question is unanswered
	// or when the only available answer is bound to a stale question ID.
	Answer(ctx context.Context, key string) (Answer, bool, error)

	// Consume marks the question identified by questionID as fully handled for
	// key, so a later Answer for the same key returns false until a new Ask.
	// It is a no-op if questionID does not match the pending question (the
	// answer belonged to a superseded pause).
	Consume(ctx context.Context, key, questionID string) error
}

QuestionStore is the transport half of the lifecycle: where a pending Question is posted and where its Answer eventually appears. It is deliberately separate from checkpoint.Store — the envelope is durable machine state, the question is friend-facing I/O — so the two can live in different systems (a GCS bucket for envelopes, a GitHub issue for questions, say).

Nonce binding is the store's responsibility: Answer must only return a reply whose QuestionID matches the Question most recently Asked for key.

type WakeDecision

type WakeDecision int

WakeDecision is the tri-state outcome of Coordinator.Wake.

const (
	// WakeFresh means no resumable checkpoint exists for the key: the caller
	// should run the reconcile from scratch. Returned when nothing is parked,
	// or when a parked envelope was past its deadline / drifted and has been
	// swept (envelope deleted, its orphaned question consumed best-effort).
	WakeFresh WakeDecision = iota

	// WakeRearm means a checkpoint is parked but not yet actionable: either the
	// question is still unanswered, or a concurrent waker already claimed it.
	// The caller should return a cheap RequeueAfter and mutate nothing.
	WakeRearm

	// WakeResume means the checkpoint was answered and successfully claimed
	// (Store CAS delete + question consumed). The accompanying WakeResult
	// carries the envelope and framed-ready answer to resume from. WakeResume
	// is never paired with a non-nil error: the CAS claim is the commit point,
	// so the caller must always resume from the WakeResult.
	WakeResume
)

func (WakeDecision) String

func (d WakeDecision) String() string

String implements fmt.Stringer for readable logs and test failures.

type WakeResult

type WakeResult struct {
	// Envelope is the claimed checkpoint envelope to rebuild the request from.
	Envelope *checkpoint.Envelope
	// Answer is the friend reply, passed raw to the executor's Resume — which
	// owns framing — and paired back to the suspending tool call.
	Answer Answer
	// Token is the CAS handle that was used to claim (delete) the envelope,
	// retained for telemetry and idempotency assertions.
	Token checkpoint.Token
}

WakeResult carries the state a WakeResume caller needs to resume a paused run. It is nil for WakeFresh and WakeRearm.

Directories

Path Synopsis
Package githubquestions implements suspend.QuestionStore over GitHub PR (or issue) comments, so the human half of an ask-a-friend pause happens where humans already are: the question appears as a comment on the conversation the paused run belongs to, and a collaborator answers by replying
Package githubquestions implements suspend.QuestionStore over GitHub PR (or issue) comments, so the human half of an ask-a-friend pause happens where humans already are: the question appears as a comment on the conversation the paused run belongs to, and a collaborator answers by replying
Package memquestions provides an in-memory suspend.QuestionStore for tests and single-process demos.
Package memquestions provides an in-memory suspend.QuestionStore for tests and single-process demos.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL