124 lines
5.7 KiB
Go
124 lines
5.7 KiB
Go
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)
|