Files
patterm/internal/app/classifier.go

103 lines
2.8 KiB
Go

package app
import (
"context"
"time"
)
// classifierTickInterval is how often the per-session classifier wakes
// up to re-evaluate every child's state. 250ms is fast enough that
// the sidebar badge looks live, slow enough that the cost is invisible
// even with dozens of children.
const classifierTickInterval = 250 * time.Millisecond
// classifierTailBytes is the size of the ring-buffer tail the
// classifier scans for promoter regexes. Big enough to catch a multi-
// line "Approve?" prompt, small enough that we don't pay for a full
// 1 MiB regex scan every tick.
const classifierTailBytes = 4096
// runClassifier loops over every live child every classifierTickInterval
// and updates IdleState when it changes. It runs until ctx is cancelled
// (the host shutdown path cancels). One goroutine per Session is plenty
// — the work is cheap (atomic loads + ~4 KiB regex scan per child).
func (s *Session) runClassifier(ctx context.Context) {
ticker := time.NewTicker(classifierTickInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.classifyAll()
}
}
}
func (s *Session) classifyAll() {
for _, c := range s.Children() {
s.classifyOne(c)
}
}
func (s *Session) classifyOne(c *Child) {
st := c.Status()
exited := st == StatusExited || st == StatusErrored
exitNonZero := false
if exited {
exitNonZero = c.ExitCode() != 0
}
idleMS := c.IdleMS()
titleIdleMS := c.TitleIdleMS()
title := c.Title()
tail := stripANSIBytes(nil, c.tailBytes(classifierTailBytes))
var screen []byte
if em := c.Emulator(); em != nil {
if txt, err := em.ScreenText(); err == nil {
screen = []byte(txt)
}
}
state, reason := classify(c.idleDetection, exited, exitNonZero, idleMS, titleIdleMS, title, tail, screen)
if c.setIdleState(state, reason) {
s.emitStateChanged(c.ID, state)
}
}
// tailBytes returns up to n bytes from the end of the ring buffer.
// Safe to call from the classifier goroutine while pumpChild writes
// from another goroutine — both serialise on ringMu.
func (c *Child) tailBytes(n int) []byte {
c.ringMu.Lock()
defer c.ringMu.Unlock()
have := int64(ringCap)
if !c.ringFull {
have = c.ringWrites
}
if have == 0 {
return nil
}
want := int64(n)
if want > have {
want = have
}
out := make([]byte, want)
// The ring layout matches StreamRead: when not full, byte k lives
// at index k; when full, the oldest byte sits at ringPos and the
// newest at (ringPos-1) mod ringCap.
if !c.ringFull {
copy(out, c.ring[c.ringWrites-want:c.ringWrites])
return out
}
// Tail starts `want` bytes back from the write head.
start := (c.ringPos - int(want) + ringCap) % ringCap
first := ringCap - start
if first > int(want) {
first = int(want)
}
copy(out, c.ring[start:start+first])
if first < int(want) {
copy(out[first:], c.ring[:int(want)-first])
}
return out
}