textmachine/platform/internal/runner/bankapply.go

124 lines
5.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)