Files
pad/internal/server/decision_tick.go
T
xarmian e598bc4f64 feat(decision): effort, effort_medium and high_stakes on the attention set, staged; drain-paced backfill (TASK-3921) (#2236)
* feat(decision): effort, effort_medium and high_stakes on the attention set, staged; one-shot backfill (TASK-3921)

- attention set gains effort (Score), effort_medium (Choice) and high_stakes
  (Noul) in the same call; needs_human_decision's text is unchanged.
- QuestionSet.Unsurfaced names the staged keys: WorkspaceFlags and
  SurfacedDecisions skip them (no flag, no state read, no counter); the
  decisions list and endpoint still return every row.
- attention chips only render keys that have a label.
- store: per-set enqueue body extracted (owedDecisionJobTx), OweDecisionJobs
  (chunked, under the installed debounce) and ItemsMissingDecisionKey.
- Runner.BackfillEffort owes the attention set once for eligible items with a
  needs_human_decision answer and no effort answer, marker
  effort_decisions_backfilled written after every chunk; hooked at the end of
  reconfigureDecisionsLocked (boot and later admin enable); makes no provider call.
- eval harness: titlebody now sends the live set; RESULTS.md rows pending.
- docs/api.md and tests updated.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* test(decision-eval): keep titlebody as the day-73 control, add titlebody-live; record the TASK-3957 and TASK-3921 runs

- titlebody again sends the day-73 pair (needs_human_decision + blocked), so it
  stays comparable with its 09-22 row as the model-drift control.
- titlebody-live sends the live attention set on the same {title, body} state:
  the TASK-3921 gate (needs_human_decision AUC 0.936, within .02 of 0.939).
- Every run prints the question keys it sends, before -dry too, so a row's
  call shape is in its own log.
- RESULTS.md: rows for the one-question call (TASK-3957) and the
  four-question call (TASK-3921).

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* fix(decision): effort backfill lifecycle, staged keys off item reads, dry-run guard (TASK-3921; #r1 F1-F4)

- F1: startEffortBackfill checks stopped and does bg.Add under one
  decisionTick.mu hold; BackfillEffort/EffortBackfillCandidates take a ctx
  checked before each candidate read and each chunk; cancel returns ctx.Err()
  and never writes the marker. stopDecisionTick cancels the run.
- F2: reconfigure cancels a running backfill; only a non-nil runner starts a
  new one. A per-run generation replaces the atomic latch so a cancelled run
  cannot clobber its successor.
- F3: item show / --agent / MCP item get use Runner.ItemReadDecisions, which
  drops the set's Unsurfaced keys; the decisions endpoint stays raw. docs/api.md
  updated.
- F4: the file dry run refuses a path with a -wal/-shm sibling or under
  $HOME/.pad (refuseLiveDatabase, tested without the env var).

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* test(decision): a chunk write that fails in the store writes no marker (TASK-3921; mutant M6)

The two failure tests fail the per-chunk test hook, so the path where
OweDecisionJobs itself returns an error was never exercised: a mutant that
ignores that error and marks anyway survived. The new test breaks the
decision_jobs table from the hook so the error comes back through the store,
asserts no marker, then restores the table and re-runs to completion.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* refactor(decision): drop the pre-marker cancel check; a cancel after the last chunk may mark (TASK-3921; M5, lead ruling)

Mutant M5 (that check removed) survived every test, and could not do
otherwise: a cancel can land there only after every chunk was owed, and the
marker means every candidate was ENQUEUED, not answered, so marking then is
true. The check is removed rather than kept as a guard no instrument can
turn red. The doc comments on BackfillEffort, cancelEffortBackfill and
startEffortBackfill now say a cancel before the last chunk writes no
marker and one after it may.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* docs(api): a backfill chunk in flight when decisions are disabled may still commit (TASK-3921; r2 L1, lead ruling bdce2116)

docs/api.md said that with no provider "no job row is ever written". That is
false for one window: a disable that lands while the one-shot effort
backfill is inside a chunk's write lets that chunk commit, and its jobs wait
until a provider returns. Ruled docs-only. The disable test's comment now
says it covers a backfill parked before its first chunk, not that window.

Sweep for the claim (CONVE-23): grep of docs, internal, cmd, scripts and
skills (.go, .md) for "no job row", "job row is", "no job is", "no jobs
are written/owed/enqueued" and "never written/owed/enqueued". Two hits are
about this mechanism: api.md:72 (fixed) and the test comment (narrowed).
Unchanged, still true:
- store/decisions.go:27 is about the WRITE DOORS, which still enqueue
  nothing without a resolver.
- runner_test.go:238 is about the nil-provider runner, which never starts a
  backfill.
- effort_backfill_lifecycle_test.go:128 covers a run parked before its
  first chunk.
The other hits are unrelated mechanisms.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* fix(decision): pace the effort backfill on drain; dry run opens a copy; docs eligibility (TASK-3921; r3 M1, L2, L4)

- M1: the backfill owes one tick pass of items per chunk (20 default, wired
  from the tick limit) and owes chunk k+1 only once chunk k's own jobs have
  drained: none claimable now, by ClaimDecisionJobs' own predicate
  (new Store.CountClaimableDecisionJobs). Debounced jobs do not hold it.
  The wait polls at the tick interval, honours ctx, and a cancel writes
  no marker; the marker still follows the last chunk being owed.
- L2: TestEffortBackfillDryRunOnFile copies the named file under GOTMPDIR
  and opens only the copy.
- L4: docs/api.md states the real eligibility and the drain pacing.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* docs(decision): the Unsurfaced comment names ItemReadDecisions (TASK-3921; r4 L2)

The field's comment listed WorkspaceFlags and SurfacedDecisions as the
readers that skip staged keys, but not ItemReadDecisions, which item show
(and so --agent and MCP item get) has used since the r1 F3 fix. Comment only.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* test(decision): break the table before the LAST chunk so the store-failure test pins M6 (TASK-3921; lead ruling)

Since the drain pacing (51c545850), the store-failure test broke the
decision_jobs table before chunk 1 of 3. Under mutant M6 (a failed chunk
write ignored) the drain wait that follows read the same broken table and
failed, so no marker was written by a different mechanism and the test stayed
green: an end-state assertion another path also produced (CONVE-12). Breaking
the table before the last chunk leaves no drain wait after it (the marker does
not wait for the last chunk to drain), so only the chunk's own error check can
keep the marker unwritten.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
2026-10-19 13:05:05 -04:00

223 lines
7.7 KiB
Go

package server
import (
"context"
"log/slog"
"sync"
"time"
"github.com/google/uuid"
"github.com/PerpetualSoftware/pad/internal/decision"
)
// The decision tick: the half of TASK-3117 that does the provider calls.
//
// Writes only mark an evaluation owed (a decision_jobs row, in the write's own
// transaction — the day-73 async-only ruling). This loop claims owed jobs and
// runs them. Same settled sweeper shape as reminder_tick.go: config struct
// with its own mutex and stop channel, tracked by Server.bg so Stop() drains
// it before the DB closes, recoverSweeper on the goroutine, and an injectable
// tick channel for tests.
//
// With no provider configured the runner is nil, the store has no resolver,
// and no job row is ever written. The loop starts the first time a provider
// is configured — at boot, or later when an admin enables one
// (ConfigureDecisions, TASK-3121). It is not stopped when a later change
// disables decisions: it keeps ticking, reads a nil runner, and does nothing.
const (
// defaultDecisionTickInterval bounds how stale a decision is after a
// write whose job is claimable: at most one interval plus the provider's
// latency. A re-ask inside its debounce window (decision.ReaskDebounce,
// TASK-3916) waits for the window first. Decisions are
// advisory reads, not alarms, so a few seconds is well inside what a
// reader would notice, and an idle tick costs one indexed scan of an
// empty table.
defaultDecisionTickInterval = 5 * time.Second
// defaultDecisionTickLimit caps jobs per pass. Each job is a provider
// round trip, evaluated sequentially, so this bounds one pass's wall
// time rather than throughput.
defaultDecisionTickLimit = 20
// defaultDecisionClaimLease must outlast ONE JOB, not one pass: the
// runner claims jobs one at a time (codex round 4), so a lease starts
// when its own evaluation does. A lease that lapses mid-call hands the
// job to another instance and buys a duplicate provider call.
//
// THE RECEIPT: typesafe.go's retry policy is maxAttempts=4 at a 60s
// requestTimeout, with up to maxRetryAfter=30s between attempts — a
// worst case of 4*60 + 3*30 = 330s per job. 10 minutes is ~1.8x that.
// A provider with a longer retry budget needs this raised with it.
defaultDecisionClaimLease = 10 * time.Minute
)
type decisionTickConfig struct {
mu sync.Mutex
runner *decision.Runner
interval time.Duration
limit int
lease time.Duration
runnerID string
stop chan struct{}
cancel context.CancelFunc
running bool
tick <-chan time.Time
// stopped latches once stopDecisionTick runs. Reconfiguration can start
// the tick at any time (TASK-3121), so without it a settings write racing
// Stop() could start a loop after Stop() had drained s.bg — a goroutine
// using a store that is about to close (codex round 1).
stopped bool
// passCancel cancels the pass in flight, if any. SetDecisionRunner calls
// it when the runner changes, so disabling or replacing the provider
// stops the OLD one being called for the rest of that pass — up to
// `limit` more jobs of item data sent to a provider the admin turned off
// (codex round 1). A cancelled job's claim is released without counting
// an attempt, so the new runner picks it up.
passCancel context.CancelFunc
// backfillCancel cancels the running effort backfill (TASK-3921), if any;
// backfillGen names the current run so a cancelled run exiting late cannot
// clear the state of the one that replaced it. Both live under mu, as the
// stopped check and the bg.Add of the run do.
backfillCancel context.CancelFunc
backfillGen uint64
}
// SetDecisionRunner attaches the decision runner and wires its registry into
// the store's write doors. A nil runner (no provider configured) removes any
// resolver, so the doors enqueue nothing.
//
// The write doors' resolver is installed BEFORE the runner is published
// (codex round 2). Disabling therefore stops new enqueues before the runner
// goes nil, rather than leaving a window after the disable in which a write
// still owes a job. A write whose transaction read the old resolver before
// that point can still commit its job afterwards: it was concurrent with the
// disable, and its row stays owed, un-run, until a provider is configured.
func (s *Server) SetDecisionRunner(r *decision.Runner) {
if s.store != nil {
r.Install(s.store)
}
s.decisionTick.mu.Lock()
if s.decisionTick.runner != r && s.decisionTick.passCancel != nil {
s.decisionTick.passCancel()
s.decisionTick.passCancel = nil
}
s.decisionTick.runner = r
s.decisionTick.mu.Unlock()
}
func (s *Server) decisionRunner() *decision.Runner {
s.decisionTick.mu.Lock()
defer s.decisionTick.mu.Unlock()
return s.decisionTick.runner
}
// SetDecisionTickChannel replaces the interval ticker with a caller-driven
// channel. Test affordance only.
func (s *Server) SetDecisionTickChannel(c <-chan time.Time) {
s.decisionTick.mu.Lock()
defer s.decisionTick.mu.Unlock()
s.decisionTick.tick = c
}
// StartDecisionTick starts the evaluation loop. Idempotent, a no-op with no
// runner attached, and a no-op once Stop() has run.
func (s *Server) StartDecisionTick() {
s.decisionTick.mu.Lock()
if s.decisionTick.running || s.decisionTick.stopped || s.decisionTick.runner == nil {
s.decisionTick.mu.Unlock()
return
}
if s.decisionTick.interval == 0 {
s.decisionTick.interval = defaultDecisionTickInterval
}
if s.decisionTick.limit == 0 {
s.decisionTick.limit = defaultDecisionTickLimit
}
if s.decisionTick.lease == 0 {
s.decisionTick.lease = defaultDecisionClaimLease
}
if s.decisionTick.runnerID == "" {
s.decisionTick.runnerID = "decision-" + uuid.NewString()
}
ctx, cancel := context.WithCancel(context.Background())
s.decisionTick.stop = make(chan struct{})
s.decisionTick.cancel = cancel
s.decisionTick.running = true
interval := s.decisionTick.interval
stop := s.decisionTick.stop
tick := s.decisionTick.tick
s.decisionTick.mu.Unlock()
slog.Info("decision tick started", "interval", interval.String())
s.bg.Add(1)
go func() {
defer s.bg.Done()
defer s.recoverSweeper("decision-tick")
var c <-chan time.Time
if tick != nil {
c = tick
} else {
t := time.NewTicker(interval)
defer t.Stop()
c = t.C
}
for {
select {
case <-stop:
return
case <-c:
s.runDecisionTick(ctx)
}
}
}()
}
// stopDecisionTick signals the loop to exit and cancels an in-flight provider
// call, so Stop() does not wait out a network round trip before closing the
// database. The runner releases a cancelled job's claim without counting an
// attempt, so it is claimable again at once.
func (s *Server) stopDecisionTick() {
s.decisionTick.mu.Lock()
defer s.decisionTick.mu.Unlock()
s.decisionTick.stopped = true
if s.decisionTick.backfillCancel != nil {
s.decisionTick.backfillCancel()
s.decisionTick.backfillCancel = nil
}
if !s.decisionTick.running {
return
}
close(s.decisionTick.stop)
s.decisionTick.cancel()
s.decisionTick.running = false
}
func (s *Server) runDecisionTick(ctx context.Context) {
// The runner and the pass's cancel are taken under ONE lock, so a swap
// either lands before (this pass uses the new runner) or after (the swap
// cancels this pass). There is no window in which the old runner runs
// uncancellable.
passCtx, cancel := context.WithCancel(ctx)
defer cancel()
s.decisionTick.mu.Lock()
r, id, limit, lease := s.decisionTick.runner, s.decisionTick.runnerID, s.decisionTick.limit, s.decisionTick.lease
s.decisionTick.passCancel = cancel
s.decisionTick.mu.Unlock()
defer func() {
s.decisionTick.mu.Lock()
s.decisionTick.passCancel = nil
s.decisionTick.mu.Unlock()
}()
n, err := r.RunOnce(passCtx, id, limit, lease)
if err != nil {
slog.Error("decision tick: claim failed", "error", err, "claimed", n)
}
}