textmachine/backend/internal/store/chunkstatus.go
2026-09-15 14:18:58 +03:00

202 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package store
import (
"database/sql"
"errors"
)
// chunkstatus.go: the per-chunk×stage disposition record (Milestone 2, D2). It is a
// materialized RESOLVE over the append-only checkpoints (keyed by request_hash,
// which already carries `attempt`), NOT a column on checkpoints. Because it is
// derivable from the checkpoints, losing it to a kill -9 self-heals: on resume
// the runner re-classifies the checkpoint text (no re-billing) and re-upserts
// the row. `disposition` is the axis the runner loop reads BEFORE rendering, so
// a terminally-flagged chunk is never re-attacked into an infinite paid loop.
//
// The store is dumb storage here: disposition/flag_reason are plain strings; the
// typed FlagReason/Disposition constants and the classifier live in the pipeline
// package.
// ChunkStatus is one (book, chapter, chunk, stage) disposition row.
type ChunkStatus struct {
BookID string
Chapter int
ChunkIdx int
Stage string
SnapshotID string
ContentHash string // signature of the rendered msgs (the source is NOT in the snapshot); guards the resume fast-path against serving a stale translation after a source edit
Disposition string // ok | flagged | skipped
FlagReason string // "" when ok
Attempts int // number of attempts made for this chunk×stage
FinalHash string // request_hash of the authoritative checkpoint (ok path)
CostUSD float64 // sum across all attempts (F3-honest)
Detail string
Escalated bool // a single-hop fallback was ATTEMPTED for this chunk×stage (D12/D15.3 telemetry)
// EscalationModel is the fallback model whose output became AUTHORITATIVE for this row — the model
// whose bytes shipped. It is EMPTY when no hop ran AND when a hop ran but also failed (the primary's
// flag stands, so no fallback output was used); `Escalated` alone records that an attempt was made
// and billed, and the hop's identity survives in its own checkpoint / request_log row.
//
// HISTORICAL MIXTURE (pack-18, deliberate — no migration): rows written before that pack carry the
// ATTEMPTED model instead, so a flagged row from an older run may name a fallback that in fact
// refused. Old rows are not rewritten; a book re-run under the current code re-resolves them.
EscalationModel string
// FirstFlagReason is the disposition reason of the FIRST attempt whenever the row's own verdict is no
// longer that failure. It is "" when the first attempt already resolved ok or when the verdict IS it,
// and it is NOT the row's verdict — FlagReason is. It exists because such a row otherwise erases the
// primary failure from every durable surface, and one of those failures (cjk_artifact) is the DeepSeek
// echo mine, whose rate is the only number watching it (D18/D19; the mini-run of 25.07 measured 0.0%
// where 1 of 20 drafts had in fact echoed).
//
// ⚠ «A LATER ATTEMPT RECOVERED THE CHUNK» USED TO STAND HERE AND IS TOO NARROW: the verdict also moves
// when a regeneration fails DIFFERENTLY, and when a run stops over the position — a stop mark carries
// the superseded failure too (pipeline/cutcall.go), precisely so an echo the book PAID for stays
// countable once the reason column says why the purchase did not happen. Whoever reads this column for
// recoveries must ask the row's disposition as well (pipeline/quality.go).
// Observability only: re-derived from the stored checkpoints on every run, never a verdict, never wire.
FirstFlagReason string
// UpdatedAt is when this row was last WRITTEN, as the store spells it (`datetime('now')`: UTC,
// `YYYY-MM-DD HH:MM:SS`, NOT NULL since the table's first schema — migrate.go). It is read, never
// written from here: UpsertChunkStatus stamps it itself. The book writer takes the newest one as the
// book's modification time — a fact of the store rather than of the clock of whichever process builds
// the file, which is what keeps two builds of one store byte-identical.
//
// «Last written», not «last changed». Two ordinary $0 operations move it with nothing changed:
// - a SKIPPED row is re-written by every run that passes it. A skip is not a stored verdict a resume
// can serve: the flagged upstream row is served from its checkpoint, and the skip is re-derived
// from it and upserted again — by recordSkippedStages for a flagged edit unit, and by the flagged
// branch of runStageSequence where a wave holds two or more stages (both pipeline/waverun.go);
// - the --resnapshot RE-PIN rewrites an unchanged row to carry the new snapshot id
// (pipeline/stagerun.go).
// An `ok` or `flagged` row is not touched by an ordinary resume — it is read and its checkpoint served,
// with no write (pipeline/resume.go). So a cleanly shipped book keeps every timestamp across a re-run
// and a book with a flagged unit does not. Pinned by
// pipeline.TestOrdinaryResumeMovesOnlyTheSkippedRow.
//
// ⚠ WHICH of the two writers wrote a row is readable off the row itself — they write different Detail
// sentences — and that is the only way to tell, since the code path is decided by the wave layout
// rather than by anything the row records. Pinned in both directions by
// pipeline.TestASkippedRowSaysWHICHWriterWroteIt, which exists because reading this paragraph was NOT
// enough: measured 08.09, breaking recordSkippedStages turned six tests red while breaking the
// runStageSequence branch survived its whole package, so half of what this paragraph asserted had no
// witness at all.
UpdatedAt string
}
// UpsertChunkStatus writes (or overwrites) the disposition row. Overwrite is the
// resolve semantics: re-running classify over the same checkpoints reproduces the
// same row, and a --resnapshot re-processing legitimately replaces a stale row.
func (s *Store) UpsertChunkStatus(cs ChunkStatus) error {
ctx, cancel := opContext()
defer cancel()
_, err := s.w.ExecContext(ctx, `
INSERT INTO chunk_status (
book_id, chapter, chunk_idx, stage, snapshot_id, content_hash,
disposition, flag_reason, attempts, final_hash, cost_usd, detail,
escalated, escalation_model, first_flag_reason, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT (book_id, chapter, chunk_idx, stage) DO UPDATE SET
snapshot_id = excluded.snapshot_id,
content_hash = excluded.content_hash,
disposition = excluded.disposition,
flag_reason = excluded.flag_reason,
attempts = excluded.attempts,
final_hash = excluded.final_hash,
cost_usd = excluded.cost_usd,
detail = excluded.detail,
escalated = excluded.escalated,
escalation_model = excluded.escalation_model,
first_flag_reason = excluded.first_flag_reason,
updated_at = excluded.updated_at`,
cs.BookID, cs.Chapter, cs.ChunkIdx, cs.Stage, cs.SnapshotID, cs.ContentHash,
cs.Disposition, cs.FlagReason, cs.Attempts, cs.FinalHash, cs.CostUSD, cs.Detail,
boolToInt(cs.Escalated), cs.EscalationModel, cs.FirstFlagReason)
return err
}
// GetChunkStatus returns the disposition row for a chunk×stage, or nil if none.
func (s *Store) GetChunkStatus(bookID string, chapter, chunkIdx int, stage string) (*ChunkStatus, error) {
ctx, cancel := opContext()
defer cancel()
cs := ChunkStatus{BookID: bookID, Chapter: chapter, ChunkIdx: chunkIdx, Stage: stage}
var escalated int
err := s.r.QueryRowContext(ctx, `
SELECT snapshot_id, content_hash, disposition, flag_reason, attempts, final_hash, cost_usd, detail,
escalated, escalation_model, first_flag_reason, updated_at
FROM chunk_status
WHERE book_id = ? AND chapter = ? AND chunk_idx = ? AND stage = ?`,
bookID, chapter, chunkIdx, stage).Scan(
&cs.SnapshotID, &cs.ContentHash, &cs.Disposition, &cs.FlagReason, &cs.Attempts, &cs.FinalHash, &cs.CostUSD, &cs.Detail,
&escalated, &cs.EscalationModel, &cs.FirstFlagReason, &cs.UpdatedAt)
cs.Escalated = escalated != 0
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &cs, nil
}
// ChunkStatusesForBook returns every disposition row for a book, ordered for a
// stable report (tmctl report: the flag section).
func (s *Store) ChunkStatusesForBook(bookID string) ([]ChunkStatus, error) {
return queryAll(s.r, `
SELECT chapter, chunk_idx, stage, snapshot_id, content_hash, disposition, flag_reason,
attempts, final_hash, cost_usd, detail, escalated, escalation_model, first_flag_reason, updated_at
FROM chunk_status WHERE book_id = ?
ORDER BY chapter, chunk_idx, stage`,
func(rows *sql.Rows) (ChunkStatus, error) {
cs := ChunkStatus{BookID: bookID}
var escalated int
if err := rows.Scan(&cs.Chapter, &cs.ChunkIdx, &cs.Stage, &cs.SnapshotID, &cs.ContentHash,
&cs.Disposition, &cs.FlagReason, &cs.Attempts, &cs.FinalHash, &cs.CostUSD, &cs.Detail,
&escalated, &cs.EscalationModel, &cs.FirstFlagReason, &cs.UpdatedAt); err != nil {
return cs, err
}
cs.Escalated = escalated != 0
return cs, nil
}, bookID)
}
// ResetChunkStages deletes the chunk_status rows AND the checkpoints for the given stages of
// ONE chunk, in a single transaction — the durable half of `tmctl redrive` (D15.3). After
// this, the next translate re-derives those stages: their chunk_status is gone (the runner
// re-runs instead of resuming the flag) and their checkpoints are gone (a FRESH provider call
// is made, not a deterministic replay of the flagged completion). Only the passed stages are
// touched — an upstream OK stage keeps its checkpoint and resumes at $0 (D12: never re-pay
// DispOK work). MONEY (documented, honest): the money already spent on the discarded attempts
// STAYS committed in `spend` (it was really billed at the provider), so the spend ceilings
// remain honest; only the resume cache and the escalation-budget accounting are reset, which
// is the "fresh retry/escalation budget" redrive grants an explicit operator command. Thus
// after a redrive committed(spend) >= SUM(checkpoints) — the safe direction (a ceiling never
// under-counts). Checkpoints are joined to jobs by (book, chapter, stage); request_log
// (append-only telemetry) is deliberately NOT touched, so the paid history is still auditable.
func (s *Store) ResetChunkStages(bookID string, chapter, chunkIdx int, stages []string) error {
if len(stages) == 0 {
return nil
}
ctx, cancel := opContext()
defer cancel()
tx, err := s.w.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for _, stage := range stages {
if _, err := tx.ExecContext(ctx, `
DELETE FROM checkpoints
WHERE chunk_idx = ? AND job_id IN (
SELECT id FROM jobs WHERE book_id = ? AND chapter = ? AND stage = ?)`,
chunkIdx, bookID, chapter, stage); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
DELETE FROM chunk_status
WHERE book_id = ? AND chapter = ? AND chunk_idx = ? AND stage = ?`,
bookID, chapter, chunkIdx, stage); err != nil {
return err
}
}
return tx.Commit()
}