journal
import "github.com/danmestas/dagnats/journal"journal/inputs.go Placement records (ADR-027). An input moves from the submissions bucket into the conversation by one input.placed record; the record is the truth about placement, so a journal knows every request_id placed in any iteration and a successor never places one twice.
Package journal is the agent journal of ADR-026: one append-only JetStream subject per step, journal.{run}.{step}, where an agent step’s SDK records what it did so a successor can take over without repeating a turn. Every append is fenced on the journal tail its writer last saw, so a worker that lost the tail (stalled past its AckWait, superseded by a redelivery) cannot write, and is told so.
A Journal is one claim on one dispatch of a step. Open reads the tail from the stream leader, stops without writing when the step has moved to a later iteration, rebuilds what this iteration needs from the records so far, and appends a claim record. Append and Commit then carry Nats-Expected-Last-Subject-Sequence and a stable Nats-Msg-Id. A rejected append is not fencing by itself: the writer re-reads the tail and only a record from another claim of the same or a later iteration fences it.
Index
- Constants
- Variables
- func MsgID(task Task, index int) string
- type Claim
- type Commit
- type InputPlaced
- type Journal
- func Open(ctx context.Context, js jetstream.JetStream, pub *natsutil.TracingPublisher, task Task) (*Journal, error)
- func (j *Journal) Append(ctx context.Context, recordType string, data []byte) (uint64, error)
- func (j *Journal) Commit(ctx context.Context, c Commit) (uint64, error)
- func (j *Journal) Committed() (Commit, bool)
- func (j *Journal) CommittedSeq() uint64
- func (j *Journal) Fenced() bool
- func (j *Journal) InputPlaced(requestID string) bool
- func (j *Journal) Model(ctx context.Context, fn ModelFunc) ([]byte, error)
- func (j *Journal) Pause(ctx context.Context, name string, duration time.Duration) (uint64, error)
- func (j *Journal) PlaceInput(ctx context.Context, p InputPlaced) (uint64, error)
- func (j *Journal) PlacedInputs() []InputPlaced
- func (j *Journal) Records() []Record
- func (j *Journal) Restarts() int
- func (j *Journal) Resume() (string, bool)
- func (j *Journal) Seq() uint64
- func (j *Journal) State() []byte
- func (j *Journal) Task() Task
- func (j *Journal) Tool(ctx context.Context, call ToolCall, replay Replay, fn ToolFunc) (ToolResult, error)
- type ModelFunc
- type ModelResponse
- type Pause
- type Record
- type Replay
- type Report
- type ScriptedModel
- type StepReport
- type Task
- type ToolCall
- type ToolFunc
- type ToolIntent
- type ToolResult
Constants
Record types. Handlers write TypeEntry; the SDK writes the rest. The model and tool types are reserved for the helpers of #805.
const (
TypeClaim = "claim"
TypePause = "pause"
TypeCommit = "commit"
TypeEntry = "entry"
TypeModelResponse = "model.response"
TypeToolIntent = "tool.intent"
TypeToolResult = "tool.result"
TypeInputPlaced = "input.placed"
)Commit actions: what the iteration’s lifecycle event will say.
const (
ActionContinue = "continue"
ActionComplete = "complete"
ActionFail = "fail"
)Record headers. The identity lives in headers so a tail read can classify a record without decoding its body.
const (
HeaderType = "Dagnats-Journal-Type"
HeaderIteration = "Dagnats-Journal-Iteration"
HeaderAttempt = "Dagnats-Journal-Attempt"
HeaderIndex = "Dagnats-Journal-Index"
HeaderDelivered = "Dagnats-Journal-Delivered"
HeaderStreamSeq = "Dagnats-Journal-Stream-Seq"
HeaderNonce = "Dagnats-Dispatch-Nonce"
)ErrInterrupted is the error text of a tool result recorded for a call a previous holder of the step started but never finished.
const ErrInterrupted = "interrupted"Variables
Sentinel errors a handler is expected to check.
var (
// ErrStale: the journal tail belongs to a later iteration, so this
// task is a late redelivery. Nothing was written; the task is done.
ErrStale = errors.New("journal: task is stale, a later iteration holds the step")
// ErrFenced: a record from another claim follows this writer's.
// Nothing more may be written or published for this task.
ErrFenced = errors.New("journal: fenced by another claim")
// ErrCommitted: this attempt already committed the iteration.
// Open found the commit; the caller republishes its event.
ErrCommitted = errors.New("journal: iteration already committed by this attempt")
// ErrRestartLimit: the crash-loop guard failed the step (ADR-028).
// The worker has published step.failed; the handler must return.
ErrRestartLimit = errors.New("journal: restart limit reached, the step has been failed")
// ErrNotProvisioned: the stream is missing.
ErrNotProvisioned = errors.New(
"journal: AGENT_JOURNAL stream not provisioned" +
" — ensure natsutil.SetupAll() has been called" +
" or start the server with 'dagnats serve'",
)
)ErrReplayMismatch: the call at this position was recorded with another name or input. The handler is not replaying the sequence of calls it recorded; nothing is reused or written.
var ErrReplayMismatch = errors.New("journal: tool call does not match the recorded call")func MsgID
func MsgID(task Task, index int) stringMsgID is the stable dedup key of a record: {run}.{step}.{attempt}.{iteration}.{stream_seq}.{delivered}.{index}. The delivery count keeps a redelivery’s records distinct from the stalled delivery’s inside the dedup window; the stream sequence does the same for a resend, which restarts the count. Without them the successor’s claim would be answered with the old claim’s sequence.
type Claim
Claim is the body of a claim record.
type Claim struct {
Iteration int `json:"iteration"`
Attempt int `json:"attempt"`
DispatchNonce string `json:"dispatch_nonce,omitempty"`
Delivered int `json:"delivered"`
StreamSeq uint64 `json:"stream_seq,omitempty"`
}type Commit
Commit is the body of a commit record: the iteration’s outcome and the state the next iteration starts from.
type Commit struct {
Action string `json:"action"`
Output json.RawMessage `json:"output,omitempty"`
State []byte `json:"state,omitempty"`
Error string `json:"error,omitempty"`
FailureType string `json:"failure_type,omitempty"`
RetryAfterMs int64 `json:"retry_after_ms,omitempty"`
// Wait is the durable wait a continue asked for (ADR-027), so a
// republished step.continue carries the same one.
Wait *protocol.ContinueWait `json:"wait,omitempty"`
}type InputPlaced
InputPlaced is the body of an input.placed record: the submission as it joined the conversation. Content is copied in so the conversation can be rebuilt from the journal alone, after the bucket’s TTL.
type InputPlaced struct {
RequestID string `json:"request_id"`
Mode string `json:"mode"`
Content json.RawMessage `json:"content,omitempty"`
// Iteration is set from the record on read; ignored on write.
Iteration int `json:"-"`
}type Journal
Journal is one claim on one dispatch of a step. A handle belongs to one goroutine: replay matches tool and model calls by position, so calls issued from several goroutines would replay in another order. A handler running a model’s parallel tool calls issues them through the journal one at a time. Concurrent use panics; a tool function may call back into the journal from the goroutine that owns it.
type Journal struct {
// contains filtered or unexported fields
}func Open
func Open(ctx context.Context, js jetstream.JetStream, pub *natsutil.TracingPublisher, task Task) (*Journal, error)Open claims task’s dispatch. It returns ErrStale when the tail belongs to a later iteration (nothing written), ErrFenced when a rival claim for the same iteration won, and a journal whose Committed reports this attempt’s own commit when the previous holder crashed between committing and publishing (no claim is appended then).
func (*Journal) Append
func (j *Journal) Append(ctx context.Context, recordType string, data []byte) (uint64, error)Append writes a record of recordType with data as its body.
func (*Journal) Commit
func (j *Journal) Commit(ctx context.Context, c Commit) (uint64, error)Commit ends the iteration with one record: the action its lifecycle event will carry and the state the next iteration starts from. One message, not an atomic batch (ADR-026). After it nothing more may be appended under this claim.
func (*Journal) Committed
func (j *Journal) Committed() (Commit, bool)Committed returns this attempt’s own commit: the one Open found, or the one Commit stored.
func (*Journal) CommittedSeq
func (j *Journal) CommittedSeq() uint64CommittedSeq returns the journal sequence of the commit Committed reports, 0 when there is none.
func (*Journal) Fenced
func (j *Journal) Fenced() boolFenced reports whether a record from another claim fenced this one.
func (*Journal) InputPlaced
func (j *Journal) InputPlaced(requestID string) boolInputPlaced reports whether requestID has an input.placed record in this journal, in any iteration, by any claim.
func (*Journal) Model
func (j *Journal) Model(ctx context.Context, fn ModelFunc) ([]byte, error)Model requests one model response durably: the response a previous holder of this attempt recorded for the call at this position is returned without calling fn; otherwise fn runs and its response is appended as model.response. A request that fails records nothing and leaves the position free, and a new attempt of the iteration requests afresh. Streaming tokens are not the journal’s business: PutStream is a non-durable preview, and a crash mid-stream re-sends the request on the next delivery. Model must be called from the goroutine that owns the handle.
func (*Journal) Pause
func (j *Journal) Pause(ctx context.Context, name string, duration time.Duration) (uint64, error)Pause writes the pause record Pause publishes before its delayed redelivery; the next claim on this iteration sees it in Resume.
func (*Journal) PlaceInput
func (j *Journal) PlaceInput(ctx context.Context, p InputPlaced) (uint64, error)PlaceInput appends an input.placed record. The caller checks InputPlaced first; placing a request twice is a programmer error.
func (*Journal) PlacedInputs
func (j *Journal) PlacedInputs() []InputPlacedPlacedInputs returns every placement in journal order, with the iteration each joined.
func (*Journal) Records
func (j *Journal) Records() []RecordRecords returns this iteration’s records written before this claim, in journal order: what a recovering attempt may reuse.
func (*Journal) Restarts
func (j *Journal) Restarts() intRestarts returns how many claims on this iteration sat at the tail before this claim with nothing between them: deliveries that claimed and then made no progress. A pause, an entry, a tool or model record, or a commit resets the count; a claim after a commit (a retry) starts from 0. Feeds the worker’s restart limit (ADR-028).
func (*Journal) Resume
func (j *Journal) Resume() (string, bool)Resume returns the pause name when the newest record of this iteration before the claim is a pause: this delivery resumes it.
func (*Journal) Seq
func (j *Journal) Seq() uint64Seq returns the last journal sequence this writer saw.
func (*Journal) State
func (j *Journal) State() []byteState returns the state of the last commit before this iteration, nil when there is none.
func (*Journal) Task
func (j *Journal) Task() TaskTask returns the dispatch this journal claims.
func (*Journal) Tool
func (j *Journal) Tool(ctx context.Context, call ToolCall, replay Replay, fn ToolFunc) (ToolResult, error)Tool runs one tool call durably: it appends the call’s intent with its replay declaration, runs fn, and appends the result. Calls are matched by position within the iteration, and a recorded call at the position must be the same call (name and input), else ErrReplayMismatch. On a claim that recovers an attempt a previous holder started: a recorded result is returned without running fn; an intent without a result is rerun only when the recorded and the current declaration both say Safe, and otherwise answered with an interrupted result carrying the partial output and the call’s key, which the model then sees. On a new attempt of the iteration (after a commit, or a higher attempt from iteration 1) only unsafe intents are honoured, matched by content rather than position: the first prior unsafe intent with the same name and input not yet honoured in this attempt answers the call under its own key, with its recorded result or as interrupted; safe calls run afresh.
The guarantee is that the SDK starts an unsafe call at most once per position. A holder stalled between its intent and the tool’s effect may still complete that effect after a successor recorded the call as interrupted; the key is what an external system deduplicates on. The returned error is the journal’s (ErrFenced, ErrCommitted, transport), never the tool’s own, which is in the result. Tool must be called from the goroutine that owns the handle; a model’s parallel tool calls are issued one at a time.
type ModelFunc
ModelFunc requests one model response. An error means no response: nothing is recorded and the next holder requests again.
type ModelFunc func(ctx context.Context) ([]byte, error)type ModelResponse
ModelResponse is the body of a model.response record.
type ModelResponse struct {
Call int `json:"call"`
Output json.RawMessage `json:"output,omitempty"`
}type Pause
Pause is the body of a pause record.
type Pause struct {
Name string `json:"name"`
DurationMs int64 `json:"duration_ms"`
}type Record
Record is one journal message.
type Record struct {
Seq uint64 `json:"seq"`
Type string `json:"type"`
Iteration int `json:"iteration"`
Attempt int `json:"attempt"`
DispatchNonce string `json:"dispatch_nonce,omitempty"`
Delivered int `json:"delivered"`
StreamSeq uint64 `json:"stream_seq,omitempty"`
Index int `json:"index"`
Time time.Time `json:"time"`
Data json.RawMessage `json:"data,omitempty"`
}func DecodeMsg
func DecodeMsg(msg jetstream.Msg) (Record, error)DecodeMsg reads one journal message, as delivered by a consumer on the journal stream, into a Record. The record’s Seq is the message’s stream sequence, the cursor clients resume from.
func Read
func Read(ctx context.Context, js jetstream.JetStream, runID, stepID string) ([]Record, error)Read returns every record of the step’s journal, in order. An unprovisioned stream is ErrNotProvisioned; an empty journal is an empty slice.
type Replay
Replay is a tool’s declaration of what a rerun costs: Safe when running the call twice is harmless (a read, an idempotent write keyed on ToolCall.Key), Unsafe when it is not (send a message, open a pull request, charge a card).
type Replay stringconst (
Safe Replay = "safe"
Unsafe Replay = "unsafe"
)type Report
Report is the result of Check for one run.
type Report struct {
RunID string `json:"run_id"`
Steps []StepReport `json:"steps"`
// Skipped names the invariants that could not be checked and why.
Skipped []string `json:"skipped,omitempty"`
}func Check
func Check(ctx context.Context, nc *nats.Conn, runID string) (Report, error)Check audits a run’s journals against its history and snapshot: each iteration has exactly one commit from the attempt the engine accepted (1); no record follows the claim that fenced its writer (2); every tool.intent has one tool.result (4); every placed input appears once (5); the engine’s iteration count equals the accepted continue commits (6). Invariant 3 needs a side-effect counter outside the journal and is reported as skipped, as is 5 while the submissions bucket does not exist.
func (Report) OK
func (r Report) OK() boolOK reports whether no step has a violation.
type ScriptedModel
ScriptedModel is a fake model for tests: it answers Model calls with its scripted responses in order and counts the requests it served, so a test can tell a reused response from a re-requested one. Safe for concurrent use.
type ScriptedModel struct {
// contains filtered or unexported fields
}func NewScriptedModel
func NewScriptedModel(responses ...[]byte) *ScriptedModelNewScriptedModel scripts the responses, in order.
func (*ScriptedModel) Requests
func (m *ScriptedModel) Requests() intRequests is how many responses the model has served.
func (*ScriptedModel) Respond
func (m *ScriptedModel) Respond(ctx context.Context) ([]byte, error)Respond is the ModelFunc: the next scripted response, or an error once the script is exhausted.
type StepReport
StepReport is one step’s audit.
type StepReport struct {
StepID string `json:"step_id"`
Records int `json:"records"`
Commits int `json:"commits"`
Violations []string `json:"violations,omitempty"`
}type Task
Task identifies one dispatch of a step: the identity every record carries and every lifecycle event names (ADR-025).
type Task struct {
RunID string
StepID string
Iteration int
Attempt int
DispatchNonce string
// Delivered is the delivery count of the task message. It is part
// of the claim identity: the engine stamps the attempt on every
// dispatch, so a stalled worker and the AckWait redelivery that
// replaces it share run, step, iteration, attempt and nonce, and
// only the delivery count tells their claims apart. It also orders
// the deliveries of one message (a newer one fences an older one
// opening after it) and feeds the crash-loop guard (#807).
Delivered int
// StreamSeq is the task message's sequence in the task stream.
// Redeliveries share it; a dispatch resend is a fresh message with
// a new one and delivery count 1 again, so only it tells a resend's
// claim from the original's while that is still in flight. Zero
// when unknown.
StreamSeq uint64
}type ToolCall
ToolCall is one tool invocation. The handler sets Name and Input; Tool fills Call (the call’s position in the iteration, counting from 0) and Key, the idempotency key {run}.{step}.{iteration}.{call}, which is the same on every delivery and attempt of the iteration so the tool can hand it to an external system that deduplicates.
type ToolCall struct {
Name string `json:"name"`
Input json.RawMessage `json:"input,omitempty"`
Call int `json:"call"`
Key string `json:"key"`
}type ToolFunc
ToolFunc runs the tool. The output it returns must be a JSON value, because it is recorded as is: encode plain text with json.Marshal. The error it returns is the tool’s own failure, recorded in the result for the model to see; it is not a reason to stop the step.
type ToolFunc func(ctx context.Context, call ToolCall) ([]byte, error)type ToolIntent
ToolIntent is the body of a tool.intent record: the call and its replay declaration, written before the tool runs.
type ToolIntent struct {
ToolCall
Replay Replay `json:"replay"`
}type ToolResult
ToolResult is the body of a tool.result record. Key is the call’s idempotency key. Interrupted marks a call a previous holder started and never finished, which was not rerun; Partial then holds, as a JSON array, the entry records that holder appended while the tool ran.
type ToolResult struct {
Call int `json:"call"`
Key string `json:"key"`
Output json.RawMessage `json:"output,omitempty"`
Error string `json:"error,omitempty"`
Interrupted bool `json:"interrupted,omitempty"`
Partial json.RawMessage `json:"partial,omitempty"`
}Generated by gomarkdoc