textmachine/platform/internal/ingest/exit.go

132 lines
6.7 KiB
Go

package ingest
import (
"errors"
"os/exec"
)
// exit.go: the engine's shell contract, as the platform reads it.
//
// It lives in this package because this is where the seam's vocabulary lives, and because three
// consumers need the SAME answer: the reconciler that turns a unit's end into a run status, the
// intake that decides what an unparseable book means, and the dev supervisor. Three copies of a
// table of numbers is three places for them to drift apart.
//
// The source is `backend/cmd/tmctl/main.go` (exitCode) and `backend/internal/pipeline/refusal.go`,
// landed as D39.131. What is written here is what those files GUARANTEE; a number this build has
// never heard of is handled by band membership below rather than by an enumeration.
const (
// ExitClean — the run finished with nothing to flag.
ExitClean = 0
// ExitFailure — an infrastructure failure, and everything the engine does not classify.
ExitFailure = 1
// ExitFlagged — the run finished and some units need a human.
ExitFlagged = 2
// ExitBankStop — the deliberate signing stop before the edit wave.
ExitBankStop = 3
// ExitCeiling — a spend ceiling stopped the run. RESUMABLE, and the whole of PD-113: the
// contract requires `paused` here and forbids `failed`.
ExitCeiling = 4
// ExitStopped — SIGINT/SIGTERM was caught and the run wound down. It says the run was
// INTERRUPTED; it does not say by whom, which is why the platform's own recorded intent is
// still what tells a user's stop from a reboot (PD-152).
ExitStopped = 5
)
// The refusal band: the invocation was turned down BEFORE it did any work of its own — nothing
// reached a provider, nothing was spent, and nothing this process would have written was written.
//
// It is a BAND and not a list because the vocabulary is the engine's and it will grow. A consumer
// looks the number up; a class this build has never heard of still lands inside the band and reads
// as "refused" rather than as "the book is unreadable". That difference is the whole of PD-196: the
// intake acts on `source_unreadable` by DELETING the user's upload, so a number it does not
// recognise must never arrive there.
const (
RefusalFirst = 10
RefusalLast = 19
// ExitConfigInvalid — the configuration will not run: unreadable, unparseable, invalid, no key.
// The operator's file is what needs fixing; the book's source is untouched and blameless.
ExitConfigInvalid = 10
// ExitSourceUnreadable — the source was read and cut and there is no book in it. The ONE class
// that says something about the user's text rather than about the deployment, and therefore the
// only one an automated intake may act on destructively.
ExitSourceUnreadable = 11
// ExitProjectLocked — another tmctl owns this project right now. Nothing is wrong with anything;
// the answer is to come back later.
ExitProjectLocked = 12
// ExitSchemaMismatch — the project's database is not this binary's schema. Nothing is wrong with
// the book: the answer is `tmctl migrate` when the file is older, or a newer binary when it is
// newer. It is the deploy deadlock of unified backlog row 174 wearing a number, and it is the one
// refusal a caller can eventually FIX rather than only wait through.
//
// ⚠ Read from the engine's tree while its own half was still in flight (`tmctl migrate`, the
// micro-prompt running in parallel on 14.08 — present and uncommitted at the time of writing).
// If that lands under a different number, THIS LINE is the only thing to change: the mapping is
// safe in both directions either way, because an unrecognised code in the band already falls to
// the non-destructive answer (books.intakeReason).
ExitSchemaMismatch = 13
// ExitRefusedOther — a refusal class this build of the engine had no number for.
ExitRefusedOther = 19
)
// Refused reports whether an exit code is in the refusal band.
func Refused(code int) bool { return code >= RefusalFirst && code <= RefusalLast }
// CompletedWithFlags reports whether an error from running the engine is exit 2 — the command DID
// its work and some units need a human.
//
// It exists because that disposition is not confined to a translation: the engine's $0 read
// commands carry it too, and `status --json` — the platform's settlement and pre-spawn channel —
// prints the whole report on stdout and THEN exits 2 whenever the book has a flagged unit
// (backend/cmd/tmctl/render.go, renderStatusJSON). A caller that reads any non-zero code as "the
// engine did not answer" therefore stops being able to read the money of every book that ever
// flagged a unit: the hold of such a run stays reserved forever and its next run is refused before
// it starts. Reading the code rather than the presence of an error is what separates a report from a
// refusal (acceptance dofix ФП-1).
func CompletedWithFlags(err error) bool {
var exit *exec.ExitError
return errors.As(err, &exit) && exit.Exited() && exit.ExitCode() == ExitFlagged
}
// Outcome is the engine's own verdict about a run, and it is deliberately readable from EITHER
// channel: it is what the terminal `finished` line carries and what OutcomeOf reads off an exit
// code. The two are one vocabulary because they are one fact travelling twice — a journal that
// could not be written still leaves the code, and a process killed before it exited still leaves
// the line.
type Outcome string
const (
OutcomeClean Outcome = "clean" // 0
OutcomeFailed Outcome = "failed" // 1 — infra failure, and anything unrecognised
OutcomeFlagged Outcome = "flagged" // 2 — completed with flagged units
OutcomeBankStop Outcome = "bank_stop" // 3 — deliberate human-in-the-loop halt
OutcomeCeiling Outcome = "ceiling" // 4 — a spend ceiling stopped it; RESUMABLE, never a failure
OutcomeStopped Outcome = "stopped" // 5 — a caught signal wound it down
// OutcomeRefused is the band, and it has no `finished` counterpart: an invocation that was
// turned down did no work, so the engine writes no verdict about work (pipeline/events.go
// `terminal`). It exists on this side because a caller reading only the code still has to tell
// "refused, nothing happened" from "ran and failed".
OutcomeRefused Outcome = "refused"
)
// OutcomeOf maps an exit code onto the verdict. An unknown code is OutcomeFailed, which is the
// engine's own rule for 1 and the safe direction for anything outside the contract.
func OutcomeOf(code int) Outcome {
switch {
case code == ExitClean:
return OutcomeClean
case code == ExitFlagged:
return OutcomeFlagged
case code == ExitBankStop:
return OutcomeBankStop
case code == ExitCeiling:
return OutcomeCeiling
case code == ExitStopped:
return OutcomeStopped
case Refused(code):
return OutcomeRefused
default:
return OutcomeFailed
}
}