textmachine/backend/internal/pipeline/events.go

698 lines
32 KiB
Go

package pipeline
import (
"context"
"errors"
"fmt"
"log/slog"
"path/filepath"
"sync"
"sync/atomic"
"time"
"textmachine/backend/internal/chunk"
"textmachine/backend/internal/obs"
"textmachine/backend/internal/runevents"
"textmachine/backend/internal/store"
)
// events.go: WHEN the run-event seam emits (row 103). The form lives in internal/runevents, the durable
// sequencing in internal/store (events_outbox); here is the driver's half — the call sites, the counters
// and the policy for a journal that cannot be written.
//
// ORDER, and it is the invariant: a fact is committed to SQLite, THEN its line is enqueued in the
// outbox, THEN the outbox is projected onto the file. Nothing is ever written to the journal that the
// database does not already hold, which is the direction that would cost money and lie to a user; the
// opposite direction — a fact committed whose line was lost to a crash — is bounded and named per event
// below, and its ratified repair channel is the `status --json` resync (D39.106 §2).
//
// The two events a lost line cannot hurt are `spend` (cumulative: the next line carries the total again)
// and `progress` (an assignment: the next line carries the counters again). The one it can is
// `unit_done`, which a reader FOLDS BY INCREMENT — see unitResolved.
// emitter writes one process's region of a book's event journal.
//
// Everything is under one mutex, including the projection: the waves emit from N workers, and the reader
// cannot survive seq N+1 appearing above seq N. Under the mutex the projection always reads the outbox
// in sequence order, so it appends a dense prefix even when two workers commit concurrently (the write
// pool is single-connection, so a transaction sees MAX(seq)=N only after N's transaction committed).
type emitter struct {
mu sync.Mutex
store *store.Store
journal *runevents.Journal
log *slog.Logger
runID string
bookID string
now func() time.Time
lastSeen int64 // the highest seq already on the file
degraded bool // the journal refused a write; the run continues (PD-60)
// marks are the announce-once keys whose line has been enqueued but not yet confirmed on the file.
marks map[int64]string
// waves is the live per-phase counter. nil until the driver knows the book's cut (beginWaves).
waves *waveCounters
// ceilingSaid records that the `ceiling` event is already on the file. The money fact is published
// where it HAPPENS (runWave's latch) so that a sibling's infra failure cannot take it down with the
// exit code — see the comment there — and terminal() must then not write a second one for the same
// stop. Guarded by mu like every other field of this struct.
ceilingSaid bool
}
// openEmitter opens the book's journal, drops the previous processes' outbox rows and writes this
// process's handshake as its first line. runID is the process identity (the trace id) and is the half of
// the ratified idempotency key (engine_run_id, seq) that makes a resumed run a new stream in the same
// file rather than a corruption of the old one.
func openEmitter(st *store.Store, dir, runID, bookID string, log *slog.Logger) (*emitter, error) {
j, err := runevents.OpenJournal(dir)
if err != nil {
return nil, err
}
// The stream identity must be FRESH, and since row 102 the caller can supply it — so this is the one
// place that checks. Reusing an id that already wrote for this book is not a small mistake: the
// sequence continues from the previous process's numbers instead of restarting at 1, and the
// projection, whose cursor starts at zero, re-appends that entire previous stream before writing a
// handshake at some seq far past 1. A reader then re-applies every counting event it already applied
// and finally refuses the handshake. Measured on the real binary before this guard: two runs under
// one TM_TRACE_ID produced 33 lines, 15 of them byte-identical duplicates, and a hello at seq 16.
//
// The run is NOT stopped for it — a paid book must not die because a caller mislabelled it — so the
// stream takes a fresh identity and says so at ERROR. The caller's id keeps naming the run in the
// logs; only the seam's key changes.
if used, err := st.EventsUsed(runID); err != nil {
j.Close()
return nil, err
} else if used {
fresh := obs.NewTraceID()
log.Error("this run was given an id that has already written run events for this book; a stream identity must be unique per process, so the event stream announces itself under a fresh one (fix the caller: TM_TRACE_ID must be unique per invocation)",
"given", runID, "stream_id", fresh)
runID = fresh
}
if err := st.ForgetEvents(runID); err != nil {
j.Close()
return nil, err
}
e := &emitter{store: st, journal: j, log: log, runID: runID, bookID: bookID, now: time.Now, marks: map[int64]string{}}
e.emit(runevents.TypeHello, runevents.Hello{
StreamVersion: runevents.StreamVersion,
EngineRunID: runID,
BookID: bookID,
ChunkerVersion: chunkerVersion,
})
return e, nil
}
func (e *emitter) close() error {
if e == nil {
return nil
}
return e.journal.Close()
}
// emit enqueues one event and projects the journal. It never returns an error, and that is the answer to
// PD-60 ("what does the engine do when the journal cannot be written"), chosen explicitly because the
// row says both positions are legitimate and a silent third is not:
//
// the engine DEGRADES LOUDLY and keeps running. A paid book must not die because a freshness side-channel
// cannot be written — the money protection is the platform's hold and the engine's own ceiling, neither
// of which depends on this file (D39.106 §2) — and the two facts a reader most needs, the ceiling halt
// and the graceful stop, also travel as distinct exit codes, so they survive a journal that never opened.
// Nothing is dropped: the lines stay in the outbox, every later event retries the whole pending prefix,
// so a transient ENOSPC or EIO heals itself and the journal ends up complete and gap-free. If it never
// heals, the reader's ratified fallback is the `status --json` resync.
//
// The rejected alternative is to BLOCK or abort the run on a journal failure. It is legitimate — it keeps
// the projection exact — but it makes the availability of a status channel a precondition for translating
// a book that has already been paid for, and on the realistic failure (a full disk) the ledger's own
// writes are failing at the same moment and the run stops anyway, one layer lower and with a better error.
func (e *emitter) emit(t runevents.Type, data any) {
if e == nil {
return
}
e.mu.Lock()
defer e.mu.Unlock()
e.enqueue(t, data)
e.project()
}
func (e *emitter) enqueue(t runevents.Type, data any) {
at := e.now()
if err := e.store.EnqueueEvent(e.runID, func(seq int64) ([]byte, error) {
return runevents.Line(seq, t, at, data)
}); err != nil {
e.log.Error("run-event outbox write failed; this event will not reach the platform's stream (the run continues; the resync channel is `tmctl status --json`)",
"event", string(t), "err", err)
}
}
// project appends every stored line the file has not got yet, in sequence order, and only THEN records
// which announce-once events a reader has now seen. The order is the point: a line still in the outbox
// when a process dies has no ledger row, so the next process announces its unit again instead of
// assuming a reader was told.
func (e *emitter) project() {
caught := 0
for {
lines, err := e.store.PendingEvents(e.runID, e.lastSeen, projectBatch)
if err != nil {
e.degrade("read the pending run events", err)
return
}
for _, l := range lines {
if err := e.journal.Append(l.Line); err != nil {
e.degrade("append to the run-event journal", err)
e.markAnnounced() // whatever DID land is still announced
return
}
e.lastSeen = l.Seq
// Per line, not per batch: what is left unmarked is what a crash makes a later process announce
// a second time, so the window is kept to a single transaction rather than the rest of the batch.
e.markAnnounced()
}
caught += len(lines)
if len(lines) < projectBatch {
break // drained: a short batch means there was nothing more to read
}
}
if e.degraded && caught > 0 {
e.degraded = false
e.log.Warn("the run-event journal is writable again; the pending events were caught up",
"caught_up", caught, "last_seq", e.lastSeen)
}
e.markAnnounced()
}
// projectBatch bounds one read of the outbox. It matters only on the failure path: while the journal
// refuses writes the cursor cannot advance, so the unprojected prefix grows with every event and an
// unbounded read would re-materialize all of it on each one, under this mutex. Large enough that a
// healthy run always drains in one read, small enough that a degraded one costs a constant.
const projectBatch = 256
// markAnnounced records the announce-once events whose line is now on the file.
func (e *emitter) markAnnounced() {
if len(e.marks) == 0 {
return
}
landed := map[int64]string{}
for seq, key := range e.marks {
if seq <= e.lastSeen {
landed[seq] = key
}
}
if len(landed) == 0 {
return
}
if err := e.store.MarkAnnounced(e.runID, landed); err != nil {
// Not fatal and not silent: the cost of losing this write is that a later process announces those
// units a second time, which an at-least-once reader is required to absorb.
e.log.Error("could not record which run events have been delivered; a later run may announce them again",
"events", len(landed), "err", err)
return
}
for seq := range landed {
delete(e.marks, seq)
}
}
// degrade reports the FIRST failure of a run of them and stays quiet afterwards: a full disk fails on
// every event, and a per-event ERROR line would bury the run's real output.
func (e *emitter) degrade(what string, err error) {
if e.degraded {
return
}
e.degraded = true
e.log.Error("could not "+what+"; the platform's live stream stalls here and resumes if this heals (the run continues — money is protected by the hold and the ceiling, not by this file; resync channel: `tmctl status --json`)",
"last_seq_on_file", e.lastSeen, "err", err)
}
// spendLine is the money event, handed to the ledger so it rides the SAME transaction that settles the
// money (D39.106 §2 "the outbox row is written in the transaction of the checkpoint"). It is the one
// event for which that matters: a journal line claiming spend the ledger never booked is a divergence
// about money, not about a progress bar.
//
// It never fails the settle. A render failure returns "no event" rather than an error, because the
// alternative is a rolled-back settle for a call the provider has already billed — losing a paid
// checkpoint to protect an indicator is the wrong way round.
func (e *emitter) spendLine() *store.SpendLine {
if e == nil {
return nil
}
at := e.now()
return &store.SpendLine{RunID: e.runID, Line: func(seq int64, committedUSD float64) ([]byte, error) {
line, err := runevents.Line(seq, runevents.TypeSpend, at,
runevents.Spend{CommittedMicroUSD: runevents.MicroUSD(committedUSD)})
if err != nil {
e.log.Error("could not render the spend event; the settle proceeds without it (the counter is cumulative — the next one carries the total again)", "err", err)
return nil, nil
}
return line, nil
}}
}
// flush projects whatever the ledger enqueued inside its own transactions (the spend event). Called
// after a settle, so money freshness does not wait for the unit to finish.
func (e *emitter) flush() {
if e == nil {
return
}
e.mu.Lock()
defer e.mu.Unlock()
e.project()
}
// waveCounters is the live per-phase progress, in OUTPUT UNITS — the granularity every engine read model
// counts in, so the stream and the `status --json` resync fold into one column on the same scale.
//
// `counted` starts as the units ALREADY resolved when this process opened the book, which is what makes
// the counter book-state rather than process-state: a resumed run walks every unit again at $0, and a
// counter that started at zero would walk a reader's progress bar backwards on every resume.
type waveCounters struct {
draft, edit runevents.Counter
counted map[unitWave]bool
// since/resolved are this PROCESS's throughput, which is what the stream's ETA is derived from: the
// wall clock since the run began working, and how many units it has resolved in it. A resumed run
// that replays everything at $0 resolves nothing and therefore offers no estimate, which is correct
// — it has measured nothing.
since time.Time
resolved int
}
// eta is the seconds remaining at the rate this run has actually achieved, or 0 when nothing has been
// measured yet (the reader renders no estimate rather than "0 s left").
func (w *waveCounters) eta(now time.Time) int {
left := (w.draft.Total - w.draft.Done) + (w.edit.Total - w.edit.Done)
if w.resolved == 0 || left <= 0 {
return 0
}
eta := int(now.Sub(w.since).Seconds() / float64(w.resolved) * float64(left))
if eta < 1 {
// Work remains, so the honest floor is a second: the field is optional in the wire form, and an
// omitted one does not leave the reader's column alone — it NULLS it (see runevents.Progress).
return 1
}
return eta
}
type unitWave struct {
wave string
chapter int
unit int
}
// beginWaves seeds the counters from the store and announces them. Called once, when the driver knows
// the book's cut, before the first wave.
func (e *emitter) beginWaves(units []editUnit, shape waveShape, stored map[chunkKey][]store.ChunkStatus) {
if e == nil {
return
}
w := &waveCounters{counted: map[unitWave]bool{}, since: e.now()}
if shape.nDraft > 0 {
w.draft.Total = len(units)
}
if shape.nEdit > 0 {
w.edit.Total = len(units)
}
for _, u := range units {
draft, edit := shape.resolved(u, unitRows(u, stored))
if draft {
w.counted[unitWave{runevents.WaveDraft, u.Chapter, u.FirstChunkIdx}] = true
w.draft.Done++
}
if edit {
w.counted[unitWave{runevents.WaveEdit, u.Chapter, u.FirstChunkIdx}] = true
w.edit.Done++
}
}
e.mu.Lock()
e.waves = w
e.mu.Unlock()
// No estimate yet: nothing has been measured, and inventing one from a previous run would be a
// number about work this process has not done.
e.emit(runevents.TypeProgress, runevents.Progress{Draft: w.draft, Edit: w.edit})
}
// unitResolved announces one output unit a WAVE has just finished with, and the counters it moved.
//
// Two different questions are asked here, and conflating them is what a first version got wrong:
//
// - HAS A READER BEEN TOLD? — answered by the announce-once ledger in the outbox, because a reader
// folds `unit_done` by INCREMENT and this is the vocabulary's only counting event. Re-announcing a
// unit a resumed run merely replayed at $0 would add a whole book to a chapter counter on every
// resume; NOT announcing one whose process died between the disposition and the insert would leave
// that chapter short forever. Only a durable ledger answers both, and it is why the once-rows
// outlive their run.
// - DOES THE COUNTER MOVE? — answered by what was already resolved when this process opened the book.
// The counters are BOOK state, not process state: a resumed run re-walks every finished unit, and a
// counter that started at zero would walk a reader's progress bar backwards.
//
// The two disagree exactly in the interesting cases, and each is then right on its own terms: a unit
// whose announcement was lost to a crash is re-announced but not re-counted, and a book translated
// before this ledger existed is announced for the first time without its counters jumping.
//
// ⚠ Residual, and it is the irreducible one: between the outbox commit below and the line reaching the
// file there is no transaction, so a process killed between those two adjacent statements loses that
// announcement for good. The class ENDS at the fold, not here — `unit_done` carries a stable
// (chapter, unit, wave) identity, so a reader that assigns instead of incrementing is exact under any
// delivery, which is what at-least-once delivery (D39.119 п.3) has always asked of a consumer. Raised
// as a diff with the pack.
func (e *emitter) unitResolved(wave string, chapter, unit int, shipped, flagged bool, reason string) {
if e == nil {
return
}
e.mu.Lock()
defer e.mu.Unlock()
at := e.now()
key := unitWave{wave, chapter, unit}
// The counters may be unavailable (their baseline read failed, beginRunEvents) — the ANNOUNCEMENT is
// not: it needs no baseline, a reader folds it per chapter, and dropping it would leave that chapter
// short forever. Only the progress line is skipped in that case.
w := e.waves
counts := false
if w != nil {
if counts = !w.counted[key]; counts {
w.counted[key] = true
if wave == runevents.WaveEdit {
w.edit.Done++
} else {
w.draft.Done++
}
w.resolved++
}
}
onceKey := unitOnceKey(e.bookID, key)
announcing, seq, err := e.store.EnqueueOnce(e.runID, onceKey, func(seq int64) ([]byte, error) {
return runevents.Line(seq, runevents.TypeUnitDone, at, runevents.UnitDone{
Chapter: chapter, Unit: unit, Wave: wave, Shipped: shipped, Flagged: flagged, Reason: reason,
})
})
if err != nil {
e.log.Error("run-event outbox write failed; this unit will not reach the platform's stream (the run continues; the resync channel is `tmctl status --json`)",
"event", string(runevents.TypeUnitDone), "wave", wave, "chapter", chapter, "unit", unit, "err", err)
}
if announcing {
e.marks[seq] = onceKey
}
if counts {
e.enqueue(runevents.TypeProgress, runevents.Progress{Draft: w.draft, Edit: w.edit, ETASeconds: w.eta(at)})
}
if announcing || counts {
e.project()
}
}
// unitOnceKey is the identity of one announcement. It carries the BOOK because the ledger lives in a
// project database and `project_db` may be shared by two books: without it the second book's units would
// collide with the first's keys and never be announced at all.
//
// A package-level function and not a method, because the READ side needs it too: the volume ceiling asks
// the same ledger whether a unit has been delivered (volume.go, deliveredUnits). A second spelling of the
// key would be a silent miss — every lookup would return "not announced" and every delivered unit would
// read as new book — so there is one derivation and both sides call it.
func unitOnceKey(bookID string, k unitWave) string {
return fmt.Sprintf("unit:%s:%s:%d:%d", bookID, k.wave, k.chapter, k.unit)
}
// ceilingReached publishes the spend ceiling AT THE MOMENT OF THE REFUSAL, with how much was missing.
//
// It is called from the wave's latch rather than from terminal() because of what PD-113 was closed with:
// two channels that fail INDEPENDENTLY, the event and the exit code. Since a ceiling stop no longer
// cancels its siblings, one of them can still fail for an unrelated reason afterwards and outrank the
// halt on the way out — and an event derived from the returned error alone would then be silent about a
// money fact that had already happened. Emitting here makes the two channels independent again.
//
// Idempotent by the flag: the first stop of a run says it, terminal() does not repeat it, and a second
// worker refused by the same ceiling adds nothing a reader could use.
func (e *emitter) ceilingReached(halt *CeilingHalt) {
if e == nil || halt == nil {
return
}
e.mu.Lock()
already := e.ceilingSaid
e.ceilingSaid = true
e.mu.Unlock()
if already {
return
}
e.emit(runevents.TypeCeiling, runevents.Ceiling{Halted: true, Scope: halt.Scope, ShortfallMicroUSD: halt.ShortfallMicroUSD})
}
// moneyLedger is what the run had delivered when the money ran out, in OUTPUT UNITS, taken from the same
// per-wave tally `progress` publishes — so the running channel and the terminal one cannot disagree.
//
// The SHIPPING wave is the one that decides: a draft-only pipeline ships its drafts, an edit pipeline
// ships edited units, and beginWaves leaves the absent wave's Total at zero, so «edit if it exists, draft
// otherwise» needs no second source of truth about the pipeline's shape. nil when the run never learned
// the book's cut — a stop before the counters were seeded has nothing honest to report.
func (e *emitter) moneyLedger() *runevents.MoneyLedger {
if e == nil {
return nil
}
e.mu.Lock()
defer e.mu.Unlock()
// ⚠ THE CONDITION IS «A CEILING WAS REACHED», not «the returned error is a halt», and the two differ
// exactly where it matters. Since a ceiling no longer cancels its siblings, a run can latch on money
// and then depart carrying a crash or an infra failure — the buyer's book is in the same state either
// way, and a ledger attached only to the tidy exit would be missing from the messy one. Nil for every
// run no ceiling stopped, which is what makes the field's PRESENCE the signal.
if !e.ceilingSaid || e.waves == nil {
return nil
}
c := e.waves.edit
if c.Total == 0 {
c = e.waves.draft
}
if c.Total == 0 {
return nil
}
deferred := c.Total - c.Done
if deferred < 0 {
deferred = 0
}
return &runevents.MoneyLedger{UnitsResolved: c.Done, UnitsDeferred: deferred}
}
// terminal writes the stream's last line — and, on a ceiling halt, the `ceiling` event before it.
//
// The ceiling is the whole of PD-113: the engine returns the stop as an ERROR, every unrecognised error
// maps to exit 1, and the platform then records `failed` for a stop its own contract says is `paused`.
// It travels twice by design — as this event and as its own exit code — because the two channels fail
// independently: a journal that could not be written still leaves the code, and a process killed before
// it exited still leaves the event.
func (e *emitter) terminal(res *BookResult, err error) {
if e == nil {
return
}
var halt *CeilingHalt
var sigStop *WaveSignatureStop
var refusal *Refusal
var panicked *obs.PanicError
switch {
case errors.As(err, &refusal):
// A refusal did no work, so the stream gets no verdict about work. Writing `failed` here would
// have the exit code say "refused, nothing happened" while the stream said the run failed.
return
case errors.As(err, &panicked):
// A CRASH ON TOP OF A CAUGHT CEILING, and the two facts do not cancel (fix list ФЧ-7). Checked
// before the halt below for the reason exitCode checks it first: the OUTCOME mirrors the exit
// code, and a process that crashed exits 1 and is not `paused`. But the ceiling is a statement
// about MONEY that already happened, and it does not become less true because a sibling worker
// died on the way out — so the event still goes, and the platform, which reads it before any exit
// code (runs/reconcile.go), lands on `paused` rather than on `failed` for a run the ceiling
// stopped. Without this the single panic took BOTH of the independent channels PD-113 was closed
// with, which is the one failure mode that fix was built to rule out.
if errors.As(err, &halt) {
e.ceilingReached(halt)
}
e.emit(runevents.TypeFinished, runevents.Finished{Outcome: runevents.OutcomeFailed, Money: e.moneyLedger()})
case errors.As(err, &halt):
e.ceilingReached(halt)
e.emit(runevents.TypeFinished, runevents.Finished{Outcome: runevents.OutcomeCeiling, Money: e.moneyLedger()})
case errors.As(err, &sigStop):
// The bank_stop event itself was written where the stop happened, with its term count.
e.emit(runevents.TypeFinished, runevents.Finished{Outcome: runevents.OutcomeBankStop, Money: e.moneyLedger()})
case errors.Is(err, context.Canceled):
// In tmctl the run context is cancelled by SIGINT/SIGTERM and by nothing else (main.go's
// signal.NotifyContext), so this is the graceful stop, not a crash. The money ledger rides along
// when a ceiling was reached first: the exit code and this word agree that a person stopped the
// run, and what the run had bought before that is still what the buyer has.
e.emit(runevents.TypeFinished, runevents.Finished{Outcome: runevents.OutcomeStopped, Money: e.moneyLedger()})
case err != nil:
// A run that latched on money and then broke for an unrelated reason lands here, and the ledger
// rides along for the same reason: the `ceiling` event already went out at the moment of the
// refusal, and a consumer that reads it needs the same picture of the book a clean ceiling stop
// would have given it. `failed` is still the right word — a process that broke is not paused.
//
// ⚠ THIS BRANCH IS LAST AMONG THE ERROR CASES AND IT WAS NOT, FOR AN HOUR. Written above the
// signature stop and the cancellation, an «err != nil && a ceiling happened» branch swallowed
// BOTH of them: a bank-stop on a book that had touched its ceiling reported `failed` while its
// exit code said 3, and a caught SIGTERM reported `failed` while its exit code said 5. Two
// channels contradicting each other about one run is precisely what this vocabulary exists to
// prevent, so the ordering is the assertion, not a style.
e.emit(runevents.TypeFinished, runevents.Finished{Outcome: runevents.OutcomeFailed, Money: e.moneyLedger()})
case res != nil && res.Flagged > 0:
e.emit(runevents.TypeFinished, runevents.Finished{Outcome: runevents.OutcomeFlagged, Volume: volumeLedger(res)})
default:
e.emit(runevents.TypeFinished, runevents.Finished{Outcome: runevents.OutcomeClean, Volume: volumeLedger(res)})
}
}
// openEvents starts this process's region of the book's journal, once per runner.
//
// A run without a trace id on its context is not an invocation the seam describes — the id IS the
// engine_run_id, half of the ratified idempotency key, and a reader refuses an empty one rather than
// collapsing every run into one namespace. tmctl always opens a traced context (main.go); an in-process
// caller that does not is driving the pipeline, not running a job, and writes no journal.
func (r *Runner) openEvents(ctx context.Context) {
if r.events != nil {
return
}
ri, ok := obs.ReqInfoFromContext(ctx)
if !ok || ri.TraceID == "" {
return
}
e, err := openEmitter(r.Store, r.journalDir(), ri.TraceID, r.Book.BookID, r.Log)
if err != nil {
// A journal that cannot even be OPENED is reported and not fatal, by the same policy emit() is:
// the run is paid for, the money is protected by the hold and the ceiling, and the reader's
// fallback channel is the `status --json` resync. Nothing here returns an error, on purpose — an
// error return would be a way for observability to stop a run, and there must not be one.
r.Log.ErrorContext(ctx, "could not open the run-event journal; the platform's live stream will be silent for this run (the run continues; resync channel: `tmctl status --json`)",
"dir", r.journalDir(), "err", err)
return
}
r.events = e
}
// beginRunEvents seeds the live per-phase counters from what the store already holds. It reads the
// stored rows once — the same read `status` makes — so the baseline is the BOOK's state and not this
// process's: a resumed run walks every finished unit again at $0, and counters that started at zero
// would walk a reader's progress bar backwards on every resume.
// It degrades rather than aborting, by the same policy emit() follows: a read taken for a progress
// indicator must not be what kills a paid run. Without the baseline the counters are simply not
// published — every other event still is.
func (r *Runner) beginRunEvents(ctx context.Context, chunks []chunk.Chunk) {
if r.events == nil {
return
}
statuses, err := r.Store.ChunkStatusesForBook(r.Book.BookID)
if err != nil {
r.Log.ErrorContext(ctx, "could not read the stored dispositions for the run-event counters; this run publishes no progress (the run continues; resync channel: `tmctl status --json`)", "err", err)
return
}
byChunk := map[chunkKey][]store.ChunkStatus{}
for _, cs := range statuses {
byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}] = append(byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}], cs)
}
r.events.beginWaves(r.outputUnits(chunks), r.waveShape(), byChunk)
}
// draftUnitTracker answers "was that the LAST member of its unit?" for the draft wave, which fans out
// over chunks while the seam counts output units.
type draftUnitTracker struct {
units []editUnit
of map[chunkKey]int // member chunk → its unit's index
at map[chunkKey]int // member chunk → its index in the wave's result slice
left []atomic.Int32
}
func newDraftUnitTracker(units []editUnit, chunks []chunk.Chunk) *draftUnitTracker {
t := &draftUnitTracker{
units: units,
of: make(map[chunkKey]int, len(chunks)),
at: make(map[chunkKey]int, len(chunks)),
left: make([]atomic.Int32, len(units)),
}
for i, ch := range chunks {
t.at[chunkKey{ch.Chapter, ch.ChunkIdx}] = i
}
for i, u := range units {
t.left[i].Store(int32(len(u.Members)))
for _, m := range u.Members {
t.of[chunkKey{m.Chapter, m.ChunkIdx}] = i
}
}
return t
}
// memberDone records one member's completion and reports the unit when it was the last one. The atomic
// is also the synchronisation point for the results slice the caller then reads: each worker writes its
// own slot BEFORE decrementing, so the worker that sees zero sees every sibling's write.
func (t *draftUnitTracker) memberDone(ch chunk.Chunk) (editUnit, bool) {
i, ok := t.of[chunkKey{ch.Chapter, ch.ChunkIdx}]
if !ok {
return editUnit{}, false
}
if t.left[i].Add(-1) != 0 {
return editUnit{}, false
}
return t.units[i], true
}
// outcome folds a unit's member drafts into the pair the stream carries. The member→result index is the
// tracker's own and is built ONCE: rebuilding it per completed unit is quadratic in the book. SHIPPED means the wave
// left something the next one can work on (or, in a draft-only pipeline, something that exports);
// FLAGGED means at least one member needs a human, which is the c-lite rule the read models already use
// — a unit can legally be both.
func (t *draftUnitTracker) outcome(u editUnit, results []stageSeqResult) (shipped, flagged bool, reason string) {
for _, m := range u.Members {
i, ok := t.at[chunkKey{m.Chapter, m.ChunkIdx}]
if !ok {
continue
}
d := results[i]
if d.flagged {
if !flagged {
flagged, reason = true, string(d.flagReason)
}
shipped = shipped || d.recovered != "" // a cosmetic strip still exports its cleaned remainder
continue
}
shipped = shipped || d.finalText != ""
}
return shipped, flagged, reason
}
// journalDir is where this book's journal lives: the directory of book.yaml — the BOOK's directory
// (D39.106 §2), which is also the working directory the platform spawns the run in and tails. It is
// deliberately NOT the project DB's directory: `project_db` may point anywhere, and the seam is anchored
// to the config the caller passed.
func (r *Runner) journalDir() string {
if r.Book.Dir != "" {
return r.Book.Dir
}
return filepath.Dir(r.Book.ProjectDB)
}
// ingestSource reads and normalizes the book's source, classifying a failure as the refusal it is: this
// is the one place where "the text we were handed is unusable" is distinguishable from "the operator's
// config or host is broken", and an automated intake acts on the user's upload from that distinction.
func (r *Runner) ingestSource() (*chunk.Document, error) {
doc, err := chunk.IngestEncoded(r.Book.SourceFile, r.Book.Encoding, r.Book.SourceLang, r.structure)
if err != nil {
return nil, refuseSource(err)
}
// The reading's own alarm, reported through the ONE accessor that carries every count: a degenerate cut
// is visible as a number here or it is visible nowhere. This work ships no validator on top of it — the
// count IS the alarm — so a count that never reaches an operator is the whole mechanism missing.
if kv := doc.IngestNotes(); kv != nil {
r.Log.Warn("the source reading has notes: page breaks ignored, documents folded or excluded, or table-of-contents targets that did not become boundaries", kv...)
}
return doc, nil
}
// volumeLedger projects the run's volume stop onto the stream, or nil when the run has nothing to report
// about its grant.
//
// nil is not an omission: a run whose grant was larger than the book, and which did nothing outside it, is
// an ordinary completion and has no volume stop at all (waverun attaches one on scope.bound()). So the
// field's PRESENCE means «a grant was in force and this run has something to say about it» — either it
// held units back, or it finished units an earlier run began without charging its own grant for them.
// `left_fresh`/`left_rework` tell the two apart: both zero means the book ended.
func volumeLedger(res *BookResult) *runevents.VolumeLedger {
if res == nil || res.Volume == nil {
return nil
}
v := res.Volume
return &runevents.VolumeLedger{
MaxUnits: v.MaxUnits, Delivered: v.Delivered, Reworked: v.Reworked,
Flagged: v.Flagged, Free: v.Free, LeftFresh: v.LeftFresh, LeftRework: v.LeftRework,
}
}