132 lines
6.7 KiB
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
|
|
}
|
|
}
|