package runner import ( "bytes" "context" "errors" "fmt" "io" "os/exec" "path/filepath" "syscall" "time" "textmachine/platform/internal/ingest" ) // bankapply.go: the correction channel — `tmctl bank-apply` run as a direct child, like the other // $0 commands (engine.go). It is NOT readEngine, because the exit code is DATA here and not only a // verdict: the refusal band tells the caller's remedy apart (re-decide / re-send the same document / // call the operator), and several classes still print the whole report on stdout on their way out. // BankApplyArgs is the verb's argv. `--dry-run` is the projection mode the contract's `preview` // maps to (17-seam-inbound-law п.7). func BankApplyArgs(workdir, decisionsPath string, preview bool) []string { args := []string{"bank-apply", "--config", filepath.Join(workdir, ConfigFile), "--decisions", decisionsPath} if preview { args = append(args, "--dry-run") } return args } // maxBankReport bounds what is read back as a report. A report itemizes at most the 5000 decisions // of one act; the cap refuses a process at the configured path that is not the engine. const maxBankReport = 64 << 20 // bankStopGrace is how long the verb gets after SIGTERM before it is killed outright. Short next to // the run supervisor's: the verb's own SIGTERM contract is "written nothing yet — drop the work" // or "the writes are last and atomic", so what the grace protects is a write already in flight. // // 30 s, not a token 10: the engine's own measurement puts a maximum document (5000 minimal // declines) at ~11.3 s of uninterruptible fold before the pre-write context check (backend // bankdecisions.go, maxDecisions) — a 10 s grace expired BEFORE the phase it exists to protect // could even begin, and the SIGKILL it fired mid-fold is what produced half-landed pairs with no // report (workflow finding, P9). 30 s clears the measured worst case with a ~2.5× margin for a // slower host; the cost of the width is only how long a wedged verb can outlive its budget. const bankStopGrace = 30 * time.Second // BankApplyOutcome is what running the verb produced, exit code and report together — the caller // maps the pair onto the contract, this layer only runs and reads. type BankApplyOutcome struct { // Report is the decoded report; Decoded says one arrived on stdout and parsed. Classes that // speak on stderr alone (config, a held project, an unmigrated schema) legitimately leave it // false. Report ingest.BankReport Decoded bool // DecodeErr is why stdout did not parse as a report, when it carried bytes that did not. DecodeErr error // ExitCode is the engine's own exit, valid when Exited. A process that did not exit on its own // — killed on the caller's deadline, or never started — leaves Exited false. ExitCode int Exited bool // Stderr is the first line of the engine's stderr — for the operator's log, never for the wire. Stderr string } // BankApply runs the verb against a book and reads its report back. // // The returned error is "the verb could not be run or read at all" — a start failure, or the // caller's context ending. An ordinary non-zero exit is NOT an error here: it comes back as the // outcome's ExitCode, usually with the report beside it. func (r *Runner) BankApply(ctx context.Context, binary, workdir, decisionsPath string, preview bool) (BankApplyOutcome, error) { cmd := exec.CommandContext(ctx, binary, BankApplyArgs(workdir, decisionsPath, preview)...) cmd.Dir = workdir var out, errOut bytes.Buffer cmd.Stdout = &limitedBuffer{buf: &out, limit: maxBankReport} cmd.Stderr = &errOut // SIGTERM, not the default SIGKILL: the engine's contract for it is graceful — nothing written // before the pre-write check, atomic renames after — and a SIGKILL mid-rename is exactly the // half-landed state class 15 exists to report, produced by us instead of by the host. The same // signal systemd's KillSignal sends the run units. cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) } cmd.WaitDelay = bankStopGrace err := cmd.Run() res := BankApplyOutcome{Stderr: string(firstLine(errOut.Bytes()))} if cmd.ProcessState != nil && cmd.ProcessState.Exited() { res.ExitCode, res.Exited = cmd.ProcessState.ExitCode(), true } if body := bytes.TrimSpace(out.Bytes()); len(body) > 0 { if rep, derr := ingest.DecodeBankReport(body); derr != nil { res.DecodeErr = derr } else { res.Report, res.Decoded = rep, true } } if err != nil && !res.Exited { // Killed, or never a process: the exit code carries nothing, so the error is the answer. // The caller's own deadline is folded in — an *exec.ExitError from a SIGKILL says less than // "the budget ran out". return res, errors.Join(fmt.Errorf("runner: tmctl bank-apply: %w: %s", err, res.Stderr), ctx.Err()) } return res, nil } // limitedBuffer refuses growth past its limit instead of buffering whatever a process feels like // printing. Refusing the WRITE is what kills the child through the exec pipe machinery — exec's // copy goroutine closes the READ end the moment a Write errors (os/exec writerDescriptor: «in case // io.Copy stopped due to write error»), and the child dies on EPIPE at its next print. drain() // carries its own Kill because the streamed commands read through StdoutPipe, where no goroutine // closes anything for the caller — a workflow review read this file as if it were that one, and // the refutation is pinned (TestAnEndlessBankApplyIsRefusedRatherThanRead). type limitedBuffer struct { buf *bytes.Buffer limit int } func (l *limitedBuffer) Write(p []byte) (int, error) { if l.buf.Len()+len(p) > l.limit { return 0, errors.New("the engine printed more than this platform will read, which no report produces") } return l.buf.Write(p) } var _ io.Writer = (*limitedBuffer)(nil)