mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
25153bcda5
* fix(decision): a failure the store refuses is still counted, and an empty message is never a supersede (BUG-4022) FailDecisionJob's UPDATE carried the failure text. When the store refused it, the attempt went unrecorded: attempts stayed 0, the backoff never grew, the permanent drop never fired, and the job was re-run (a paid provider call) every lease, forever. It now fails open: one fallback UPDATE with the same WHERE and lease records the attempt with a fixed last_error, plus the SQLSTATE when it has the fixed five-character shape. Both errors are returned only if the fallback is refused too. A failure whose cause has no text (nil, or Error() == "") was read as a supersede, releasing the claim with no attempt and no last_error, so the job was re-run on every tick. releaseDecisionJob is split into releaseDecisionClaim (complete, release) and recordDecisionFailure (fail), and an empty cause records a fixed message. Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV * fix(decision): the fallback counts an attempt only if the first write did not (BUG-4022 r1 H1) Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
1118 lines
39 KiB
Go
1118 lines
39 KiB
Go
package decision
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
"github.com/PerpetualSoftware/pad/internal/store"
|
|
)
|
|
|
|
// The runner: storage and evaluation of typed decisions about items
|
|
// (PLAN-3114 unit 2, TASK-3117).
|
|
//
|
|
// A write marks an evaluation owed (store.enqueueDecisionJobsTx, in the
|
|
// write's own transaction); the server's tick claims owed jobs and calls
|
|
// [Runner.Evaluate] for each. Nothing on a write path waits on the network.
|
|
|
|
// QuestionSet is a named group of questions asked about an item in ONE
|
|
// provider call.
|
|
type QuestionSet struct {
|
|
// Name is the registry key and the stored question_set. Server-chosen,
|
|
// never caller text.
|
|
Name string
|
|
|
|
// Collections restricts the set to items in these collection slugs.
|
|
// Empty means every collection.
|
|
Collections []string
|
|
|
|
// Questions maps a stable question key to the question. A key is stored
|
|
// with every answer, so renaming one orphans its history.
|
|
Questions map[string]Question
|
|
|
|
// Unsurfaced names question keys that are asked and stored but that no
|
|
// surfacing read acts on yet (TASK-3921: staged before their reader).
|
|
// WorkspaceFlags, SurfacedDecisions (counters included) and
|
|
// ItemReadDecisions (item show, its --agent projection and MCP item get)
|
|
// skip them; Decisions, and the decisions endpoint that reads it, still
|
|
// return the rows.
|
|
Unsurfaced map[string]bool
|
|
|
|
// Eligible, when set, narrows the set to items whose CURRENT state it
|
|
// accepts. It is checked by the runner at evaluation time, never by the
|
|
// store's resolver: the resolver runs inside the write transaction and
|
|
// sees only a collection slug, while a predicate like "not terminal"
|
|
// needs the item's fields and its collection's schema. An ineligible
|
|
// item's job is dropped without a provider call; the next write
|
|
// re-enqueues it, so an item reopened later is evaluated then.
|
|
Eligible func(item *models.Item, coll *models.Collection) bool
|
|
|
|
// Resolve, when set, supplies the questions per WORKSPACE at evaluation
|
|
// time, in place of Questions (TASK-3119: one question per active
|
|
// convention, which change when a convention is edited or switched off).
|
|
// It is called on every evaluation and every currency check, so an
|
|
// answer to a question no longer asked is never current. An empty map
|
|
// means nothing to ask: the job completes with no provider call.
|
|
Resolve func(ctx context.Context, s *store.Store, workspaceID string) (map[string]Question, error)
|
|
|
|
// WithLinks adds the item's parent and blocking links to the state
|
|
// (TASK-3119: a convention whose subject is a relation cannot be judged
|
|
// without them). Only a set that asks for it gets them: the shared
|
|
// builder's bytes, and so every other set's state hashes, are unchanged.
|
|
WithLinks bool
|
|
|
|
// NoTrail leaves the comment trail out of the state (TASK-3119 U2a). The
|
|
// conventions set asks about the ITEM: a trail is discussion, it was
|
|
// clipped at 2,000 runes per comment (so a break past that was invisible
|
|
// anyway), and it was most of every call's input. Comments are asked about
|
|
// one at a time instead (U2b). The U2 gates measured this state against
|
|
// U0's labels: precision held at 100%, recall rose on two conventions, and
|
|
// no state was truncated.
|
|
NoTrail bool
|
|
|
|
// PerComment asks the set's questions about each of the item's recent
|
|
// comments, one comment per state, instead of about the item
|
|
// (TASK-3119 U2b; see per_comment.go). Row keys carry the comment id.
|
|
PerComment bool
|
|
|
|
// MaxPerCall bounds the questions sent in one provider call; a larger
|
|
// set is split into several calls. Zero sends them all in one.
|
|
MaxPerCall int
|
|
|
|
// Threshold is the Noul probability at or above which an answer in this
|
|
// set is surfaced anywhere (a dashboard flag, a chip, the item brief).
|
|
// Below it the product shows nothing, so a read that serves agents must
|
|
// not serve it either (TASK-3618). Zero surfaces every Noul answer.
|
|
Threshold float64
|
|
}
|
|
|
|
// Surfacing is the verdict of [surfacing] on one stored answer.
|
|
type Surfacing int
|
|
|
|
const (
|
|
// Surfaced: the question is current and the answer reaches the threshold.
|
|
Surfaced Surfacing = iota
|
|
// BelowThreshold: the question is current, but the answer is not a Noul
|
|
// at or above the threshold.
|
|
BelowThreshold
|
|
// StaleQuestion: the answer is to a question no longer asked as it was
|
|
// (reworded, re-resolved, another model pinned, or no longer registered).
|
|
StaleQuestion
|
|
)
|
|
|
|
// surfacing is THE row test every surfacing read applies (TASK-3618):
|
|
// WorkspaceFlags for the dashboard and SurfacedDecisions for the item brief.
|
|
// qhashNow is the fingerprint of the question as registered now, "" when
|
|
// there is none. It says nothing about the ITEM's currency: each caller
|
|
// decides what an item that moved on means for it.
|
|
func surfacing(d models.ItemDecision, qhashNow string, threshold float64) (float64, Surfacing) {
|
|
if qhashNow == "" || d.QuestionHash != qhashNow {
|
|
return 0, StaleQuestion
|
|
}
|
|
p, ok := NoulValue(d)
|
|
if !ok || p < threshold {
|
|
return p, BelowThreshold
|
|
}
|
|
return p, Surfaced
|
|
}
|
|
|
|
func (qs QuestionSet) appliesTo(collectionSlug string) bool {
|
|
if len(qs.Collections) == 0 {
|
|
return true
|
|
}
|
|
for _, c := range qs.Collections {
|
|
if c == collectionSlug {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Registry holds the registered question sets. Production builds its registry
|
|
// with ProductionRegistry (attention.go); tests register their own.
|
|
type Registry struct {
|
|
mu sync.RWMutex
|
|
sets map[string]QuestionSet
|
|
}
|
|
|
|
// NewRegistry returns an empty registry.
|
|
func NewRegistry() *Registry { return &Registry{sets: map[string]QuestionSet{}} }
|
|
|
|
// Register adds a set, refusing an invalid one or a duplicate name: a second
|
|
// registration under one name would silently change what stored answers mean.
|
|
func (r *Registry) Register(qs QuestionSet) error {
|
|
if qs.Name == "" {
|
|
return errors.New("decision: question set has no name")
|
|
}
|
|
if len(qs.Questions) == 0 && qs.Resolve == nil {
|
|
return fmt.Errorf("decision: question set %q has no questions", qs.Name)
|
|
}
|
|
if len(qs.Questions) > 0 && qs.Resolve != nil {
|
|
return fmt.Errorf("decision: question set %q has both Questions and Resolve", qs.Name)
|
|
}
|
|
for key, q := range qs.Questions {
|
|
if key == "" {
|
|
return fmt.Errorf("decision: question set %q has an empty question key", qs.Name)
|
|
}
|
|
if err := q.Validate(); err != nil {
|
|
return fmt.Errorf("decision: question set %q, question %q: %w", qs.Name, key, err)
|
|
}
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if _, dup := r.sets[qs.Name]; dup {
|
|
return fmt.Errorf("decision: question set %q is already registered", qs.Name)
|
|
}
|
|
r.sets[qs.Name] = qs
|
|
return nil
|
|
}
|
|
|
|
// Get returns a registered set.
|
|
func (r *Registry) Get(name string) (QuestionSet, bool) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
qs, ok := r.sets[name]
|
|
return qs, ok
|
|
}
|
|
|
|
// SetsFor names the sets that apply to a collection, sorted. It is the
|
|
// store's DecisionSetResolver, so it must stay pure (no database access): the
|
|
// store calls it inside the write transaction.
|
|
func (r *Registry) SetsFor(collectionSlug string) []string {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
var out []string
|
|
for name, qs := range r.sets {
|
|
if qs.appliesTo(collectionSlug) {
|
|
out = append(out, name)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// The state bounds. They live beside the builder because they are PART OF THE
|
|
// HASH: changing one changes every item's state bytes, and so marks every
|
|
// stored answer not-current until it is re-evaluated. That is correct — the
|
|
// model would be reading different state — but it is a fleet-wide re-ask, so
|
|
// change them deliberately.
|
|
const (
|
|
// RecentTrailWindow is how many of an item's newest comments the state
|
|
// carries.
|
|
RecentTrailWindow = 10
|
|
|
|
// maxStateBodyRunes bounds the item body. The provider truncates only a
|
|
// STRING state; a structured one over its budget is refused outright
|
|
// (typesafe.go fitState), so the builder bounds the body itself — and
|
|
// bounding it HERE, before hashing, is what keeps the hashed bytes
|
|
// identical to the bytes sent.
|
|
maxStateBodyRunes = 16000
|
|
|
|
// maxStateCommentRunes bounds each trail comment, for the same reason.
|
|
maxStateCommentRunes = 2000
|
|
)
|
|
|
|
// ItemState is the state a question set is asked about. Its JSON encoding is
|
|
// what the provider receives and what state_hash is computed over — one
|
|
// serialisation, see [BuildItemState].
|
|
type ItemState struct {
|
|
Title string `json:"title"`
|
|
Collection string `json:"collection"`
|
|
Fields map[string]any `json:"fields"`
|
|
Body string `json:"body"`
|
|
Trail []ItemStateRemark `json:"recent_trail"`
|
|
// Links is present only for a set built WithLinks (TASK-3119), and then
|
|
// always, as [] when the item has none: "this item has no parent" is
|
|
// exactly what a relation convention needs to read. A pointer so nil
|
|
// is omitted and every other set's bytes stay as they were.
|
|
Links *[]ItemStateLink `json:"links,omitempty"`
|
|
}
|
|
|
|
// ItemStateLink is one of the item's parent or blocking links, as the item
|
|
// sees it: relation is "parent", "blocks" or "blocked_by".
|
|
type ItemStateLink struct {
|
|
Relation string `json:"relation"`
|
|
Ref string `json:"ref"`
|
|
Title string `json:"title"`
|
|
}
|
|
|
|
// ItemStateRemark is one trail comment. The timestamp is included because
|
|
// "the last comment was three weeks ago" is a fact about the item; the
|
|
// comment's id is not, since it carries no meaning a model could read.
|
|
type ItemStateRemark struct {
|
|
Author string `json:"author"`
|
|
Body string `json:"body"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// BuiltState is a built, serialised state.
|
|
type BuiltState struct {
|
|
// Bytes is the exact JSON sent to the provider.
|
|
Bytes json.RawMessage
|
|
// Hash is hex sha256 of Bytes.
|
|
Hash string
|
|
// Truncated reports that the builder shortened the body or a comment.
|
|
Truncated bool
|
|
}
|
|
|
|
// BuildItemState builds an item's state from its row and trail.
|
|
//
|
|
// ONE SERIALISATION PATH: the bytes returned are both hashed and sent. The
|
|
// runner passes them to the provider as json.RawMessage, which the provider's
|
|
// own json.Marshal emits verbatim (RawMessage compaction is the identity on
|
|
// json.Marshal output — pinned by a test that captures the request body).
|
|
// Hashing a separately-built value would be a second path that could drift.
|
|
//
|
|
// What is DELIBERATELY absent: seq, updated_at, sort orders, ids. None is
|
|
// something a question can read, and each changes on writes that change
|
|
// nothing else — a role-board reorder, a wiki-link re-resolution. Including
|
|
// them would make those writes cost a provider call (TASK-3117 ruling 2).
|
|
func BuildItemState(item *models.Item, comments []models.Comment) (BuiltState, error) {
|
|
return buildItemState(item, comments, nil)
|
|
}
|
|
|
|
// BuildItemStateWithLinks is BuildItemState plus the item's links, for a set
|
|
// built WithLinks. links may be empty but not nil: the member is then [].
|
|
func BuildItemStateWithLinks(item *models.Item, comments []models.Comment, links []ItemStateLink) (BuiltState, error) {
|
|
if links == nil {
|
|
links = []ItemStateLink{}
|
|
}
|
|
return buildItemState(item, comments, &links)
|
|
}
|
|
|
|
func buildItemState(item *models.Item, comments []models.Comment, links *[]ItemStateLink) (BuiltState, error) {
|
|
fields := map[string]any{}
|
|
if item.Fields != "" {
|
|
dec := json.NewDecoder(strings.NewReader(item.Fields))
|
|
dec.UseNumber() // a stored 3.0 must not re-encode as 3 and move the hash
|
|
if err := dec.Decode(&fields); err != nil {
|
|
return BuiltState{}, fmt.Errorf("decision state: item %s fields: %w", item.ID, err)
|
|
}
|
|
}
|
|
body, truncated := clipRunes(item.Content, maxStateBodyRunes)
|
|
st := ItemState{
|
|
Title: item.Title,
|
|
Collection: item.CollectionSlug,
|
|
Fields: fields,
|
|
Body: body,
|
|
Trail: []ItemStateRemark{},
|
|
Links: links,
|
|
}
|
|
if len(comments) > RecentTrailWindow {
|
|
comments = comments[len(comments)-RecentTrailWindow:]
|
|
}
|
|
for _, c := range comments {
|
|
cb, ct := clipRunes(c.Body, maxStateCommentRunes)
|
|
truncated = truncated || ct
|
|
st.Trail = append(st.Trail, ItemStateRemark{
|
|
Author: c.Author,
|
|
Body: cb,
|
|
CreatedAt: c.CreatedAt.UTC().Format(time.RFC3339),
|
|
})
|
|
}
|
|
b, err := json.Marshal(st)
|
|
if err != nil {
|
|
return BuiltState{}, fmt.Errorf("decision state: marshal item %s: %w", item.ID, err)
|
|
}
|
|
sum := sha256.Sum256(b)
|
|
return BuiltState{Bytes: b, Hash: hex.EncodeToString(sum[:]), Truncated: truncated}, nil
|
|
}
|
|
|
|
func clipRunes(s string, n int) (string, bool) {
|
|
if utf8.RuneCountInString(s) <= n {
|
|
return s, false
|
|
}
|
|
i := 0
|
|
for pos := range s {
|
|
if i == n {
|
|
return s[:pos], true
|
|
}
|
|
i++
|
|
}
|
|
return s, false
|
|
}
|
|
|
|
// QuestionFingerprint is hex sha256 of the model and one question's full
|
|
// definition. An answer is current only while its fingerprint still matches:
|
|
// rewording the instructions, changing a criterion, or re-pinning the model
|
|
// changes what was asked, and the old answer is not an answer to the new
|
|
// question (codex round 6).
|
|
//
|
|
// Marshalled from a fixed struct, so field order is the struct's and map keys
|
|
// (Choice options) are sorted by encoding/json: one question has one print.
|
|
func QuestionFingerprint(model string, q Question) string {
|
|
b, _ := json.Marshal(struct {
|
|
Model string `json:"model"`
|
|
Kind Kind `json:"kind"`
|
|
Instructions string `json:"instructions"`
|
|
Options map[string]string `json:"options,omitempty"`
|
|
Levels []string `json:"levels,omitempty"`
|
|
TrueDesc string `json:"true_desc,omitempty"`
|
|
FalseDesc string `json:"false_desc,omitempty"`
|
|
}{model, q.Kind, q.Instructions, q.Options, q.Levels, q.TrueDesc, q.FalseDesc})
|
|
sum := sha256.Sum256(b)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// Runner evaluates owed decisions.
|
|
type Runner struct {
|
|
store *store.Store
|
|
provider Provider
|
|
registry *Registry
|
|
|
|
// beforeAsk is a TEST-ONLY seam, nil in production: it runs immediately
|
|
// before the final liveness check that precedes the provider call, so a
|
|
// test can land a delete in exactly the window that check exists for.
|
|
beforeAsk func()
|
|
|
|
// usage, when set, is told what every provider call consumed, by set
|
|
// (TASK-3119: the per-write spend must be visible before any Cloud
|
|
// rollout). The server wires it to its metrics.
|
|
usage func(set string, u Usage)
|
|
|
|
// backfillChunk overrides effortBackfillChunk when positive, and
|
|
// beforeBackfillChunk, when set, runs before each chunk is owed (chunk
|
|
// index from 0) and may fail the backfill there. Both are TEST-ONLY seams.
|
|
backfillChunk int
|
|
backfillPoll time.Duration
|
|
beforeBackfillChunk func(ctx context.Context, chunk int) error
|
|
|
|
// logger receives the per-evaluation spend line; nil means slog.Default().
|
|
// A field rather than the global so a test can read its own lines.
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// SetUsageObserver installs fn, called after every provider call with the
|
|
// set's name and the call's usage. nil removes it. Not safe to call while
|
|
// jobs run; the server sets it once at startup.
|
|
func (r *Runner) SetUsageObserver(fn func(set string, u Usage)) {
|
|
if r != nil {
|
|
r.usage = fn
|
|
}
|
|
}
|
|
|
|
// NewRunner returns a runner, or nil when provider is nil — the configured-off
|
|
// case. Every method is nil-safe, so the server holds a *Runner and never
|
|
// branches on the provider itself.
|
|
func NewRunner(s *store.Store, p Provider, reg *Registry) *Runner {
|
|
if p == nil || s == nil {
|
|
return nil
|
|
}
|
|
if reg == nil {
|
|
reg = NewRegistry()
|
|
}
|
|
return &Runner{store: s, provider: p, registry: reg}
|
|
}
|
|
|
|
// Registry returns the runner's registry (nil for a nil runner).
|
|
func (r *Runner) Registry() *Registry {
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
return r.registry
|
|
}
|
|
|
|
// Provider returns the runner's provider (nil for a nil runner). For a
|
|
// non-nil runner this is never nil either — [NewRunner] returns nil
|
|
// whenever its Provider argument is nil, so the two are never out of sync.
|
|
//
|
|
// Exposed for a caller that needs a plain synchronous Ask — a single
|
|
// question answered against caller-supplied state, with no enqueue, no
|
|
// idempotency check, and nothing stored (PLAN-3114 unit 5, TASK-3120: the
|
|
// playbook-match endpoint). [Runner.Evaluate] is the wrong tool for that:
|
|
// it drives the owed-jobs pipeline (registry lookup by name, state built
|
|
// from an ITEM's row, a stored idempotency check, and a written
|
|
// models.ItemDecision row), all of which a one-off Choice over free text has
|
|
// no use for.
|
|
func (r *Runner) Provider() Provider {
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
return r.provider
|
|
}
|
|
|
|
// Install wires the runner's registry into the store's write doors. With a
|
|
// nil runner it removes any resolver, so the doors enqueue nothing.
|
|
func (r *Runner) Install(s *store.Store) {
|
|
if r == nil {
|
|
s.SetDecisionSetResolver(nil)
|
|
s.SetDecisionDebounce(nil)
|
|
return
|
|
}
|
|
s.SetDecisionSetResolver(r.registry.SetsFor)
|
|
s.SetDecisionDebounce(r.debounceFor)
|
|
}
|
|
|
|
// ReaskDebounce is how long after an item's latest evaluation for a set a new
|
|
// write's re-ask waits (TASK-3916; the 1h window was accepted for IDEA-3906,
|
|
// whose trail carries the measured saving). A first evaluation never waits.
|
|
const ReaskDebounce = time.Hour
|
|
|
|
// debounceFor is the window the store applies per set: ReaskDebounce for an
|
|
// item-level set, none for a PerComment one, whose calls are one per comment
|
|
// and which a debounce cannot merge, and none for a set not registered.
|
|
func (r *Runner) debounceFor(setName string) time.Duration {
|
|
qs, ok := r.registry.Get(setName)
|
|
if !ok || qs.PerComment {
|
|
return 0
|
|
}
|
|
return ReaskDebounce
|
|
}
|
|
|
|
// ErrItemGone reports that the item no longer exists or is soft-deleted: the
|
|
// job is dropped rather than retried.
|
|
var ErrItemGone = errors.New("decision: item is gone")
|
|
|
|
// ErrWorkspaceDeleted reports that the item's workspace is soft-deleted. The
|
|
// job is HELD, not dropped: a restore must find the work still owed.
|
|
var ErrWorkspaceDeleted = errors.New("decision: item's workspace is soft-deleted")
|
|
|
|
// ErrUnknownSet reports a job for a set no longer registered: dropped.
|
|
var ErrUnknownSet = errors.New("decision: question set is not registered")
|
|
|
|
// ErrSetNotApplicable reports a job for a set that does not cover the item's
|
|
// CURRENT collection — the item moved after the job was owed: dropped.
|
|
var ErrSetNotApplicable = errors.New("decision: question set does not apply to the item's collection")
|
|
|
|
// State builds the item's current state, as a set without links sees it.
|
|
func (r *Runner) State(itemID string) (*models.Item, BuiltState, error) {
|
|
return r.stateFor(itemID, QuestionSet{})
|
|
}
|
|
|
|
// stateFor builds the item's current state as qs sees it: with its links
|
|
// when qs is WithLinks, and without the comment trail when qs is NoTrail.
|
|
func (r *Runner) stateFor(itemID string, qs QuestionSet) (*models.Item, BuiltState, error) {
|
|
item, err := r.store.GetItem(itemID)
|
|
if err != nil {
|
|
return nil, BuiltState{}, fmt.Errorf("decision: read item %s: %w", itemID, err)
|
|
}
|
|
if item == nil || item.DeletedAt != nil {
|
|
return nil, BuiltState{}, ErrItemGone
|
|
}
|
|
var comments []models.Comment
|
|
if !qs.NoTrail {
|
|
comments, err = r.store.RecentComments(itemID, RecentTrailWindow)
|
|
if err != nil {
|
|
return nil, BuiltState{}, err
|
|
}
|
|
}
|
|
if !qs.WithLinks {
|
|
st, err := BuildItemState(item, comments)
|
|
return item, st, err
|
|
}
|
|
links, err := r.stateLinks(itemID)
|
|
if err != nil {
|
|
return nil, BuiltState{}, err
|
|
}
|
|
st, err := BuildItemStateWithLinks(item, comments, links)
|
|
return item, st, err
|
|
}
|
|
|
|
// stateLinks is the item's parent and blocking links as the item sees them,
|
|
// in a fixed order so the hash moves only when the links do.
|
|
func (r *Runner) stateLinks(itemID string) ([]ItemStateLink, error) {
|
|
rows, err := r.store.GetItemLinks(itemID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decision: read links of %s: %w", itemID, err)
|
|
}
|
|
out := []ItemStateLink{}
|
|
for _, l := range rows {
|
|
switch {
|
|
case l.LinkType == models.ItemLinkTypeParent && l.SourceID == itemID:
|
|
out = append(out, ItemStateLink{Relation: "parent", Ref: l.TargetRef, Title: l.TargetTitle})
|
|
case l.LinkType == models.ItemLinkTypeBlocks && l.SourceID == itemID:
|
|
out = append(out, ItemStateLink{Relation: "blocks", Ref: l.TargetRef, Title: l.TargetTitle})
|
|
case l.LinkType == models.ItemLinkTypeBlocks && l.TargetID == itemID:
|
|
out = append(out, ItemStateLink{Relation: "blocked_by", Ref: l.SourceRef, Title: l.SourceTitle})
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Relation != out[j].Relation {
|
|
return out[i].Relation < out[j].Relation
|
|
}
|
|
if out[i].Ref != out[j].Ref {
|
|
return out[i].Ref < out[j].Ref
|
|
}
|
|
return out[i].Title < out[j].Title
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
// questionsFor is the set's questions for the item's workspace now: its
|
|
// fixed Questions, or what Resolve returns. Each resolved question is
|
|
// validated, so a malformed one fails the evaluation instead of being sent.
|
|
func (r *Runner) questionsFor(ctx context.Context, qs QuestionSet, workspaceID string) (map[string]Question, error) {
|
|
if qs.Resolve == nil {
|
|
return qs.Questions, nil
|
|
}
|
|
qmap, err := qs.Resolve(ctx, r.store, workspaceID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decision: resolve set %q: %w", qs.Name, err)
|
|
}
|
|
for k, q := range qmap {
|
|
if k == "" {
|
|
return nil, fmt.Errorf("decision: set %q resolved an empty question key", qs.Name)
|
|
}
|
|
if err := q.Validate(); err != nil {
|
|
return nil, fmt.Errorf("decision: set %q, question %q: %w", qs.Name, k, err)
|
|
}
|
|
}
|
|
return qmap, nil
|
|
}
|
|
|
|
// Evaluate asks the named set about the item, unless answers for its current
|
|
// state are already stored. It reports whether the provider was called.
|
|
func (r *Runner) Evaluate(ctx context.Context, itemID, setName string) (called bool, err error) {
|
|
if r == nil {
|
|
return false, nil
|
|
}
|
|
qs, ok := r.registry.Get(setName)
|
|
if !ok {
|
|
return false, ErrUnknownSet
|
|
}
|
|
if qs.PerComment {
|
|
return r.evaluatePerComment(ctx, qs, itemID)
|
|
}
|
|
item, st, err := r.stateFor(itemID, qs)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
// Applicability is re-checked HERE, against the collection the item is in
|
|
// now, not trusted from enqueue time: a job owed before a move still names
|
|
// the old collection's set (codex round 2). Checked before the idempotency
|
|
// read so an inapplicable set never costs a call or a query.
|
|
if ok, err := r.appliesNow(qs, item); err != nil {
|
|
return false, err
|
|
} else if !ok {
|
|
return false, ErrSetNotApplicable
|
|
}
|
|
questions, err := r.questionsFor(ctx, qs, item.WorkspaceID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if len(questions) == 0 {
|
|
// Nothing to ask in this workspace now (no convention in scope):
|
|
// the job is done, with no call.
|
|
return false, nil
|
|
}
|
|
keys := make([]string, 0, len(questions))
|
|
qhash := make(map[string]string, len(questions))
|
|
for k, q := range questions {
|
|
keys = append(keys, k)
|
|
qhash[k] = QuestionFingerprint(r.provider.Model(), q)
|
|
}
|
|
sort.Strings(keys)
|
|
have, err := r.store.HasItemDecisionsAtState(item.ID, qs.Name, st.Hash, qhash)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if have {
|
|
return false, nil
|
|
}
|
|
|
|
if r.beforeAsk != nil {
|
|
r.beforeAsk()
|
|
}
|
|
// Liveness again, as the LAST statement before the send (codex round 4):
|
|
// the check in State ran before the idempotency read, and a delete landing
|
|
// in between must still stop the call. RESIDUAL, stated rather than
|
|
// implied: no fence can hold across a network request, so a delete that
|
|
// commits after this read and before the provider receives the bytes is
|
|
// not stopped — the window is one statement plus the request, per job.
|
|
//
|
|
// This is the ONE liveness guard on the send path. A second, earlier copy
|
|
// in State was redundant with it (mutant M26 survived its removal), so it
|
|
// is gone; State serves the read path too, where nothing is sent. It runs
|
|
// inside the loop below, immediately before each call.
|
|
//
|
|
// One call, or several of at most MaxPerCall questions each (in key
|
|
// order, so a set is always split the same way). Rows are written only
|
|
// once every call has answered: a partial set would fail the idempotency
|
|
// check and be re-asked in full anyway.
|
|
answers := make(map[string]Answer, len(keys))
|
|
var usage Usage
|
|
calls := 0
|
|
// One line per evaluation that reached the provider, whatever came of it,
|
|
// so the spend is readable from the server log on a box whose /metrics is
|
|
// out of reach (TASK-3119).
|
|
defer func() {
|
|
if calls > 0 {
|
|
r.logSpend(qs.Name, item, len(keys), calls, usage, st.Truncated || usage.StateTruncated, err)
|
|
}
|
|
}()
|
|
for _, chunk := range chunkKeys(keys, qs.MaxPerCall) {
|
|
cq := make(map[string]Question, len(chunk))
|
|
for _, k := range chunk {
|
|
cq[k] = questions[k]
|
|
}
|
|
// Liveness as the LAST statement before EACH send (codex r1 on
|
|
// TASK-3119): a split set makes several calls, and a delete landing
|
|
// between them must stop the rest. A call already made is reported
|
|
// as made.
|
|
itemLive, wsLive, err := r.store.ItemLiveness(item.ID)
|
|
if err != nil {
|
|
return calls > 0, err
|
|
}
|
|
if !itemLive {
|
|
return calls > 0, ErrItemGone
|
|
}
|
|
if !wsLive {
|
|
return calls > 0, ErrWorkspaceDeleted
|
|
}
|
|
got, u, err := r.provider.Ask(ctx, st.Bytes, cq)
|
|
calls++
|
|
if r.usage != nil {
|
|
r.usage(qs.Name, u)
|
|
}
|
|
usage.InputTokens += u.InputTokens
|
|
usage.OutputTokens += u.OutputTokens
|
|
usage.StateTruncated = usage.StateTruncated || u.StateTruncated
|
|
if err != nil {
|
|
return true, err
|
|
}
|
|
for k, a := range got {
|
|
answers[k] = a
|
|
}
|
|
}
|
|
rows := make([]models.ItemDecision, 0, len(answers))
|
|
for _, key := range keys {
|
|
a, ok := answers[key]
|
|
if !ok {
|
|
// The provider contract is one answer per question; a missing
|
|
// one is a provider defect, and storing a partial set would make
|
|
// the idempotency check re-ask forever.
|
|
return true, fmt.Errorf("decision: provider returned no answer for %q", key)
|
|
}
|
|
raw, err := json.Marshal(storedAnswerOf(a))
|
|
if err != nil {
|
|
return true, err
|
|
}
|
|
rows = append(rows, models.ItemDecision{
|
|
ItemID: item.ID,
|
|
QuestionSet: qs.Name,
|
|
QuestionKey: key,
|
|
Kind: string(a.Kind),
|
|
Answer: raw,
|
|
Confidence: a.Confidence,
|
|
Provider: r.provider.Name(),
|
|
Model: r.provider.Model(),
|
|
StateHash: st.Hash,
|
|
QuestionHash: qhash[key],
|
|
ItemSeq: item.Seq,
|
|
StateTruncated: st.Truncated || usage.StateTruncated,
|
|
})
|
|
}
|
|
return true, r.store.InsertItemDecisions(item.WorkspaceID, rows)
|
|
}
|
|
|
|
// logSpend writes the evaluation's spend line: counts and identifiers only,
|
|
// never a question, an answer, the item's text or an error message (a
|
|
// provider error can quote the request).
|
|
func (r *Runner) logSpend(set string, item *models.Item, questions, calls int, u Usage, truncated bool, err error) {
|
|
l := r.logger
|
|
if l == nil {
|
|
l = slog.Default()
|
|
}
|
|
outcome := "stored"
|
|
if err != nil {
|
|
outcome = "failed"
|
|
}
|
|
l.Info("decision evaluation",
|
|
"set", set,
|
|
"workspace_id", item.WorkspaceID,
|
|
"item_ref", item.Ref,
|
|
"item_id", item.ID,
|
|
"questions", questions,
|
|
"calls", calls,
|
|
"input_tokens", u.InputTokens,
|
|
"output_tokens", u.OutputTokens,
|
|
"truncated", truncated,
|
|
"outcome", outcome)
|
|
}
|
|
|
|
// storedAnswer is the persisted shape of an [Answer]: the provider's wire
|
|
// vocabulary, with only the members the kind carries.
|
|
type storedAnswer struct {
|
|
Type Kind `json:"type"`
|
|
Choice *string `json:"choice,omitempty"`
|
|
Score *float64 `json:"score,omitempty"`
|
|
Noul *float64 `json:"noul,omitempty"`
|
|
Legend map[string]string `json:"legend,omitempty"`
|
|
Probabilities map[string]float64 `json:"probabilities,omitempty"`
|
|
Confidence *float64 `json:"confidence,omitempty"`
|
|
}
|
|
|
|
func storedAnswerOf(a Answer) storedAnswer {
|
|
out := storedAnswer{Type: a.Kind, Legend: a.Legend, Probabilities: a.Probabilities, Confidence: a.Confidence}
|
|
switch a.Kind {
|
|
case KindChoice:
|
|
c := a.Choice
|
|
out.Choice = &c
|
|
case KindScore:
|
|
sc := a.Score
|
|
out.Score = &sc
|
|
case KindNoul:
|
|
n := a.Noul
|
|
out.Noul = &n
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Decisions returns the item's latest answer per question, each marked
|
|
// Current when it was computed from the item's present state. A nil runner
|
|
// returns an empty list: no provider, no decisions, no error.
|
|
func (r *Runner) Decisions(itemID string) ([]models.ItemDecision, error) {
|
|
rows, _, err := r.decisionsNow(itemID)
|
|
return rows, err
|
|
}
|
|
|
|
// SetBackfillSeams sets the effort backfill's chunk size and its
|
|
// before-each-chunk hook. Test affordance only (the server's tests live in
|
|
// another package).
|
|
func (r *Runner) SetBackfillSeams(chunk int, hook func(ctx context.Context, chunk int) error) {
|
|
if r == nil {
|
|
return
|
|
}
|
|
if chunk > 0 {
|
|
r.backfillChunk = chunk
|
|
}
|
|
r.beforeBackfillChunk = hook
|
|
}
|
|
|
|
// SetBackfillPacing sets how many items one backfill chunk owes (the tick's
|
|
// per-pass limit) and how often the backfill re-checks that the chunk drained
|
|
// (the tick interval). Non-positive values keep the defaults.
|
|
func (r *Runner) SetBackfillPacing(chunk int, poll time.Duration) {
|
|
if r == nil {
|
|
return
|
|
}
|
|
r.backfillChunk = chunk
|
|
r.backfillPoll = poll
|
|
}
|
|
|
|
// ItemReadDecisions is Decisions without the staged keys (TASK-3921): what the
|
|
// item read, the CLI --agent projection and MCP item get carry. The raw
|
|
// Decisions stays behind the decisions endpoint, which shows staged data.
|
|
func (r *Runner) ItemReadDecisions(itemID string) ([]models.ItemDecision, error) {
|
|
rows, err := r.Decisions(itemID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]models.ItemDecision, 0, len(rows))
|
|
for _, d := range rows {
|
|
qs, _ := r.registry.Get(d.QuestionSet)
|
|
if qs.Unsurfaced[d.QuestionKey] { // the one filter line
|
|
continue
|
|
}
|
|
out = append(out, d)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// SurfacedDecisions is the item's latest answers that its set would surface
|
|
// (TASK-3618, for the item brief): the question is current and the answer
|
|
// reaches the set's Threshold, by the same row test WorkspaceFlags applies.
|
|
// Every other row is only counted: belowThreshold for a current question
|
|
// whose answer does not reach it, staleQuestion for an answer to a question
|
|
// no longer asked as it was.
|
|
//
|
|
// An answer whose ITEM has moved on is kept, with Current false: an agent
|
|
// about to work the item is better told that a surfaced answer may be out of
|
|
// date than not told at all. WorkspaceFlags keeps such an answer only while
|
|
// its re-ask is pending (TASK-3915); the brief keeps it regardless.
|
|
func (r *Runner) SurfacedDecisions(itemID string) (surfaced []models.ItemDecision, belowThreshold, staleQuestion int, err error) {
|
|
rows, qhashes, err := r.decisionsNow(itemID)
|
|
if err != nil {
|
|
return nil, 0, 0, err
|
|
}
|
|
surfaced = []models.ItemDecision{}
|
|
for i, d := range rows {
|
|
qs, _ := r.registry.Get(d.QuestionSet)
|
|
// A staged key (TASK-3921) is not surfaced and not counted.
|
|
if qs.Unsurfaced[d.QuestionKey] {
|
|
continue
|
|
}
|
|
switch _, v := surfacing(d, qhashes[i], qs.Threshold); v {
|
|
case Surfaced:
|
|
surfaced = append(surfaced, d)
|
|
case BelowThreshold:
|
|
belowThreshold++
|
|
default:
|
|
staleQuestion++
|
|
}
|
|
}
|
|
return surfaced, belowThreshold, staleQuestion, nil
|
|
}
|
|
|
|
// decisionsNow is Decisions plus, per row, the fingerprint of its question as
|
|
// registered now ("" when there is none), for the surfacing test.
|
|
func (r *Runner) decisionsNow(itemID string) ([]models.ItemDecision, []string, error) {
|
|
if r == nil {
|
|
return []models.ItemDecision{}, nil, nil
|
|
}
|
|
rows, err := r.store.LatestItemDecisions(itemID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return []models.ItemDecision{}, nil, nil
|
|
}
|
|
item, _, err := r.State(itemID)
|
|
if err != nil {
|
|
if errors.Is(err, ErrItemGone) {
|
|
return []models.ItemDecision{}, nil, nil
|
|
}
|
|
return nil, nil, err
|
|
}
|
|
qhashes := make([]string, len(rows))
|
|
// Each set is judged against ITS state (with links or not) and ITS
|
|
// questions as they stand now (a resolved set re-resolves), built once
|
|
// per set.
|
|
type setNow struct {
|
|
st BuiltState
|
|
questions map[string]Question
|
|
ok bool
|
|
}
|
|
nows := map[string]*setNow{}
|
|
nowFor := func(name string) (*setNow, error) {
|
|
if n, seen := nows[name]; seen {
|
|
return n, nil
|
|
}
|
|
n := &setNow{}
|
|
nows[name] = n
|
|
qs, registered := r.registry.Get(name)
|
|
if !registered {
|
|
return n, nil
|
|
}
|
|
_, st, err := r.stateFor(itemID, qs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
qm, err := r.questionsFor(context.Background(), qs, item.WorkspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
n.st, n.questions, n.ok = st, qm, true
|
|
return n, nil
|
|
}
|
|
model := r.provider.Model()
|
|
applies := map[string]bool{}
|
|
commentHashes := map[string]string{}
|
|
for i := range rows {
|
|
// Current needs all of these to still hold: the item state, the
|
|
// question as registered now under the model pinned now, and the set
|
|
// still applying to the item. A set or key no longer registered has
|
|
// no "now" to match, so it is not current. The last is not implied by
|
|
// the state hash: a collection schema edit can make an item terminal,
|
|
// and so ineligible, without changing a byte of its state (codex
|
|
// round 2 on TASK-3118).
|
|
now, err := nowFor(rows[i].QuestionSet)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
qhashNow := ""
|
|
stateNow := now.st.Hash
|
|
key := rows[i].QuestionKey
|
|
if qs, reg := r.registry.Get(rows[i].QuestionSet); reg && qs.PerComment {
|
|
// A per-comment row is judged against ITS comment as it stands
|
|
// now, at any age (TASK-3119 U2b): a comment gone or edited is
|
|
// not current; ten newer comments do not change that.
|
|
base, commentID, ok := SplitCommentKey(key)
|
|
if !ok {
|
|
continue
|
|
}
|
|
h, err := r.commentStateNow(item, commentID, commentHashes)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if h == "" {
|
|
continue
|
|
}
|
|
key, stateNow = base, h
|
|
}
|
|
if now.ok {
|
|
if q, ok := now.questions[key]; ok {
|
|
qhashNow = QuestionFingerprint(model, q)
|
|
}
|
|
}
|
|
qhashes[i] = qhashNow
|
|
if !(now.ok && rows[i].StateHash == stateNow && qhashNow != "" && rows[i].QuestionHash == qhashNow) {
|
|
continue
|
|
}
|
|
qs, _ := r.registry.Get(rows[i].QuestionSet)
|
|
ok, seen := applies[qs.Name]
|
|
if !seen {
|
|
ok, err = r.appliesNow(qs, item)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
applies[qs.Name] = ok
|
|
}
|
|
rows[i].Current = ok
|
|
}
|
|
return rows, qhashes, nil
|
|
}
|
|
|
|
// appliesNow reports whether the set would be asked about the item as it
|
|
// stands: its collection is covered and, when the set has one, its Eligible
|
|
// predicate accepts the item. Evaluate refuses to ASK on the same rule, and
|
|
// the read paths refuse to call an answer current on it, so an answer is never
|
|
// presented for an item the set would not ask about today.
|
|
func (r *Runner) appliesNow(qs QuestionSet, item *models.Item) (bool, error) {
|
|
if !qs.appliesTo(item.CollectionSlug) {
|
|
return false, nil
|
|
}
|
|
if qs.Eligible == nil {
|
|
return true, nil
|
|
}
|
|
coll, err := r.store.GetCollection(item.CollectionID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("decision: read collection %s: %w", item.CollectionID, err)
|
|
}
|
|
return coll != nil && qs.Eligible(item, coll), nil
|
|
}
|
|
|
|
// Job-handling bounds.
|
|
const (
|
|
// maxDecisionAttempts: a job PERMANENTLY refused this many times in a row
|
|
// is dropped. The next write to the item re-enqueues it, so dropping loses
|
|
// nothing a user did — it stops a refused state (an over-budget item, a
|
|
// malformed request) from costing a call every backoff forever. A
|
|
// TRANSIENT failure is never dropped; see isPermanentFailure.
|
|
maxDecisionAttempts = 5
|
|
// decisionRetryBase is the first backoff; it doubles per attempt...
|
|
decisionRetryBase = 30 * time.Second
|
|
// ...up to decisionRetryCap, which is what a provider outage costs: one
|
|
// call per owed job per hour until the provider is back.
|
|
decisionRetryCap = time.Hour
|
|
)
|
|
|
|
// isPermanentFailure reports whether a failed evaluation says something about
|
|
// the REQUEST rather than about the provider's availability (codex round 6).
|
|
//
|
|
// Permanent: the provider answered 4xx other than 429 — the request itself is
|
|
// the problem, and asking again unchanged gets the same answer. Everything else
|
|
// is transient: 5xx, 429/529 after the provider's own retries, transport
|
|
// errors. A transient failure must never drop the job, or an outage would
|
|
// silently discard every evaluation owed while it lasted.
|
|
func isPermanentFailure(err error) bool {
|
|
var apiErr *APIError
|
|
if errors.As(err, &apiErr) {
|
|
return apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 && apiErr.StatusCode != http.StatusTooManyRequests
|
|
}
|
|
return false
|
|
}
|
|
|
|
func decisionBackoff(attempts int) time.Duration {
|
|
d := decisionRetryBase
|
|
for i := 0; i < attempts && d < decisionRetryCap; i++ {
|
|
d *= 2
|
|
}
|
|
if d > decisionRetryCap {
|
|
d = decisionRetryCap
|
|
}
|
|
return d
|
|
}
|
|
|
|
// RunOnce evaluates up to limit owed jobs, CLAIMING ONE AT A TIME, and
|
|
// returns how many it claimed.
|
|
//
|
|
// One at a time so that each job's lease starts when its own evaluation does
|
|
// (codex round 4). Claiming the whole pass up front made the last job wait
|
|
// behind every earlier provider call: at the provider's worst case (4 attempts
|
|
// x 60s plus up to 30s between them, ~5.5 min per job) twenty jobs is far past
|
|
// any lease, and an expired claim is taken by another instance — a duplicate
|
|
// evaluation. A single job's worst case fits inside the lease; see
|
|
// defaultDecisionClaimLease in the server.
|
|
func (r *Runner) RunOnce(ctx context.Context, runnerID string, limit int, lease time.Duration) (int, error) {
|
|
if r == nil {
|
|
return 0, nil
|
|
}
|
|
n := 0
|
|
for n < limit {
|
|
if ctx.Err() != nil {
|
|
break // stopping: claim nothing further
|
|
}
|
|
jobs, err := r.store.ClaimDecisionJobs(runnerID, 1, lease)
|
|
if err != nil {
|
|
return n, err
|
|
}
|
|
if len(jobs) == 0 {
|
|
break
|
|
}
|
|
n++
|
|
r.runJob(ctx, jobs[0])
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func (r *Runner) runJob(ctx context.Context, j store.DecisionJob) {
|
|
_, err := r.Evaluate(ctx, j.ItemID, j.QuestionSet)
|
|
var serr error
|
|
switch {
|
|
case err != nil && ctx.Err() != nil:
|
|
// Cancelled by shutdown, or by the provider being swapped or disabled
|
|
// mid-pass (TASK-3121) — not a verdict on the job: no attempt counted.
|
|
r.release(j)
|
|
return
|
|
case errors.Is(err, ErrWorkspaceDeleted):
|
|
// Held for a restore, not a failure: released with no attempt, and
|
|
// the claim scan will not offer it again while the workspace stays
|
|
// deleted.
|
|
r.release(j)
|
|
return
|
|
case err == nil:
|
|
serr = r.store.CompleteDecisionJob(j)
|
|
case errors.Is(err, ErrItemGone), errors.Is(err, ErrUnknownSet), errors.Is(err, ErrSetNotApplicable):
|
|
serr = r.store.DropDecisionJob(j)
|
|
case isPermanentFailure(err) && j.Attempts+1 >= maxDecisionAttempts:
|
|
slog.Warn("decision job dropped after repeated refusals",
|
|
"item_id", j.ItemID, "question_set", j.QuestionSet, "attempts", j.Attempts+1, "error", err)
|
|
serr = r.store.DropDecisionJob(j)
|
|
default:
|
|
slog.Info("decision job failed, will retry",
|
|
"item_id", j.ItemID, "question_set", j.QuestionSet, "attempt", j.Attempts+1,
|
|
"permanent", isPermanentFailure(err), "error", err)
|
|
serr = r.store.FailDecisionJob(j, err, decisionBackoff(j.Attempts))
|
|
}
|
|
if serr != nil {
|
|
// FailDecisionJob already falls back to a fixed message when only the
|
|
// text is refused (BUG-4022), so a failure is counted either way. What
|
|
// is left is a store that refuses everything, a failed complete or
|
|
// drop, or a failure write whose outcome is unknown (its error came
|
|
// back but the attempt was already counted). The claim's lease still
|
|
// bounds it, and the job is re-run when it lapses.
|
|
slog.Error("decision job outcome not recorded", "item_id", j.ItemID, "question_set", j.QuestionSet, "error", serr)
|
|
}
|
|
}
|
|
|
|
func (r *Runner) release(j store.DecisionJob) {
|
|
if err := r.store.ReleaseDecisionJob(j); err != nil {
|
|
slog.Error("decision job not released", "item_id", j.ItemID, "question_set", j.QuestionSet, "error", err)
|
|
}
|
|
}
|
|
|
|
// chunkKeys splits keys into runs of at most n; n <= 0 is one run.
|
|
func chunkKeys(keys []string, n int) [][]string {
|
|
if n <= 0 || len(keys) <= n {
|
|
return [][]string{keys}
|
|
}
|
|
var out [][]string
|
|
for len(keys) > n {
|
|
out = append(out, keys[:n])
|
|
keys = keys[n:]
|
|
}
|
|
return append(out, keys)
|
|
}
|