package ingest import ( "errors" "io/fs" "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, no work needs rolling back and a retry is safe. // // ⚠ «NOTHING WAS WRITTEN» IS NOT THE BAND'S PROMISE, and this copy used to say it was (unified // backlog row 246). The producer withdrew it explicitly: «"Nothing was written" is NOT the band's // promise any more, it is a clause of the individual classes: exit 15 (write incomplete) // legitimately answers with files on disk» (backend/cmd/tmctl/main.go, the refusal-band comment). // The MONEY half of the promise stands in both copies and is what the band is actually for. A // consumer therefore keys a destructive act on a CLASS it knows and never on band membership — // which is what the one destructive consumer here already does, and why the correction is a comment // rather than a code change (see the next paragraph). // // 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. ⚠ The radius of that act is ONE class and not the band: // `books.intakeReason` deletes on 11 alone, and every other number in the band leaves the upload // where it is. 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 // ExitDecisionsRejected — the correction document was read and declined WHOLE (bank-apply, // D39.158): all-or-nothing, nothing written, and the USER is who re-decides. The class always // prints its report beside the code, the caps included — the per-decision reasons ARE its // product (pipeline.ApplyBankDecisions). ExitDecisionsRejected = 14 // ExitWriteIncomplete — the correction document was ACCEPTED and the write did not complete // (bank-apply). The remedy is to re-send the SAME document: the retry converges on the engine's // byte no-op, whether nothing landed or half did. ⚠ This is the class that carries files on disk // into the refusal band, and the reason the band's own promise above is about MONEY and not about // writes. ExitWriteIncomplete = 15 // ExitBookIncomplete — `build` refused to write a book with holes; the message lists them // (backend/cmd/tmctl/main.go `exitBookIncomplete`). It is the ONE refusal class of the export // door, and the door never sees it: the door always asks for the marked copy (`--partial`, // D39.178 п.1), so a refusal here means the flag did not reach the engine. Refusing by default // stays an OPERATOR's handle on the CLI. ExitBookIncomplete = 16 // 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. func CompletedWithFlags(err error) bool { var exit *exec.ExitError return errors.As(err, &exit) && exit.Exited() && exit.ExitCode() == ExitFlagged } // DeploymentFault reports an error from running the engine that is about the HOST rather than about // the book: the binary could not be executed at all, a signal killed it, or it refused over a // configuration, another process's lock, or a schema an upgrade has not migrated. // // It exists so that a per-book attempt budget is not spent by a condition every book on the host // shares: five passes of a broken deployment would otherwise write off every book in turn. The // intake reaches the same verdict through its own vocabulary (books.intakeReason → // waitsForTheDeployment), because it must also NAME the class on the wire; this answers only the // question a budget needs. // // ⚠ It answers FALSE for anything that did not come from running the engine — a malformed artifact, // a directory that is gone — because those are about the one book and are exactly what a budget is // for. Callers pass it failures of every kind, so a default of "the host's fault" would make the // budget unreachable. func DeploymentFault(err error) bool { var exit *exec.ExitError if errors.As(err, &exit) { if !exit.Exited() { return true // killed by a signal: not an answer about the book } switch exit.ExitCode() { case ExitConfigInvalid, ExitProjectLocked, ExitSchemaMismatch: return true } return false } return errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrPermission) } // 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 } }