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 ¶
func (c *Coordinator) Wake(ctx context.Context, key string) (WakeDecision, *WakeResult, error)
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. |