# proclive: add process-liveness package

`dee13d6`→[main](/content/gh/entireio/cli/commits/main/index.html)·  Soph·3w ago·8 files·+570 added/-0 removed

New leaf package that captures a process's identity (PID + start-time fingerprint, plus host/boot guards) and reports whether that exact process is still alive. ResolveOwner walks up the process tree to the first non-shell, non-entire ancestor (the agent that spawned our hook), skipping the Go toolchain too so local-dev's `go run` wrapper isn't mistaken for the owner; it records no owner at all when the hostname can't be determined, since a PID is only meaningful on its own machine.

Check returns Alive/Dead/Unknown — Dead on a missing PID, start-time mismatch (PID reuse), or reboot, and Unknown when it can't confirm the host/boot or the platform can't introspect (Windows), so callers fail closed to a timeout rather than trusting a stale PID. darwin records no boot guard: kern.boottime drifts when the wall clock is stepped (NTP), and darwin's absolute P_starttime already distinguishes a reused PID across reboots; Linux uses ticks-since-boot and keeps the boot_id guard.

Stdlib + golang.org/x/sys/unix only, so session/strategy/cli can import it without an import cycle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

## Sessions

## Changes

8
- cmd/entire/cli/proclive
  
  - Aproc_darwin.go+42
  
  - Aproc_linux.go+74
  
  - Aproc_linux_test.go+56
  
  - Aproc_other.go+16
  
  - Aproc_other_test.go+21
  
  - Aproclive.go+195
  
  - Aproclive_live_test.go+111
  
  - Aproclive_test.go+55

```go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

//go:build darwin

package proclive

import (
	"fmt"
	"golang.org/x/sys/unix"
)

// procStat looks up a process via sysctl(kern.proc.pid) and returns its parent
// PID, executable name (comm), and start time as the fingerprint. It uses the
// typed KinfoProc decoder from golang.org/x/sys/unix rather than hand-decoding
// raw sysctl bytes. p_starttime is an absolute wall-clock timeval, so it is a
// stable per-process fingerprint without needing the boot guard.
func procStat(pid int) (ppid int, name, start string, err error) {
	k, err := unix.SysctlKinfoProc("kern.proc.pid", pid)
	if err != nil {
		// A missing process surfaces as ESRCH or as EIO (sysctl returns a
		// zero-length result, which the wrapper rejects). Either way it's gone.
		if err == unix.ESRCH || err == unix.EIO || err == unix.ENOENT {
			return 0, "", "", errProcessGone
		}
		return 0, "", "", fmt.Errorf("proclive: sysctl kern.proc.pid %d: %w", pid, err)
	}
	tv := k.Proc.P_starttime
	return int(k.Eproc.Ppid),
		unix.ByteSliceToString(k.Proc.P_comm[:]),
		fmt.Sprintf("%d.%06d", tv.Sec, tv.Usec),
		nil
}

// bootID returns no boot guard on darwin. kern.boottime is NOT stable for a
// running machine — the kernel recomputes it whenever the wall clock is stepped
// (e.g. an NTP correction), so using it would let a clock adjustment falsely
// declare a still-running session dead. It is also unnecessary here: darwin's
// P_starttime fingerprint is an absolute wall-clock timestamp fixed at process
// creation, so it already distinguishes a reused PID across reboots without a
// boot guard. (Linux uses ticks-since-boot, which does need the guard.)
func bootID() (string, error) {
	return "", nil
}
```

```go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
//go:build linux

package proclive

import (
	"errors"
	"fmt"
	"os"
	"strconv"
	"strings"
)

// procStat reads /proc/<pid>/stat and returns the parent PID, executable name
// (comm), and the process start time (field 22, in clock ticks since boot) used
// as the start fingerprint. The fingerprint only needs to be stable for the
// process lifetime and distinct across PID reuse within a boot; the boot guard
// in Check invalidates it across reboots, so raw ticks suffice and we avoid
// needing _SC_CLK_TCK.
func procStat(pid int) (ppid int, name, start string, err error) {
	data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return 0, "", "", errProcessGone
		}
		return 0, "", "", fmt.Errorf("proclive: read /proc/%d/stat: %w", pid, err)
	}
	return parseProcStat(string(data))
}

// parseProcStat parses the contents of /proc/<pid>/stat. It is separated from
// the file read so it can be unit-tested with adversarial comm values.
//
// The comm (field 2) is wrapped in parentheses and may itself contain spaces
// and ')'. Everything before the first '(' is the PID; the comm runs to the
// LAST ')'; the remaining space-separated fields begin at 'state' (field 3).
func parseProcStat(content string) (ppid int, name, start string, err error) {
	openIdx := strings.IndexByte(content, '(')
	closeIdx := strings.LastIndexByte(content, ')')
	if openIdx < 0 || closeIdx < 0 || closeIdx < openIdx {
		return 0, "", "", errors.New("proclive: malformed /proc stat: no comm parens")
	}
	name = content[openIdx+1 : closeIdx]

// Fields after the comm, 0-indexed: 0=state (field 3), 1=ppid (field 4),
	// ... 19=starttime (field 22).
	const ppidIdx, starttimeIdx = 1, 19
	rest := strings.Fields(content[closeIdx+1:])
	if len(rest) <= starttimeIdx {
		return 0, "", "", errors.New("proclive: truncated /proc stat")
	}
	ppid, err = strconv.Atoi(rest[ppidIdx])
	if err != nil {
		return 0, "", "", fmt.Errorf("proclive: parse ppid: %w", err)
	}
	return ppid, name, rest[starttimeIdx], nil
}

// bootID returns the kernel boot id, which changes on every reboot. It falls
// back to /proc/stat's btime line if boot_id is unavailable.
func bootID() (string, error) {
	if data, err := os.ReadFile("/proc/sys/kernel/random/boot_id"); err == nil {
		return strings.TrimSpace(string(data)), nil
	}
	data, err := os.ReadFile("/proc/stat")
	if err != nil {
		return "", fmt.Errorf("proclive: read /proc/stat: %w", err)
	}
	for _, line := range strings.Split(string(data), "\n") {
		if rest, ok := strings.CutPrefix(line, "btime "); ok {
			return strings.TrimSpace(rest), nil
		}
	}
	return "", nil
}
```

```go
1
2
3
4
5
6
7
8
9
10
11
12
//go:build !linux && !darwin

package proclive

// On platforms without process introspection (e.g. Windows), the seam reports
// "unsupported". Check then yields LivenessUnknown and ResolveOwner yields no
// owner, so session liveness degrades cleanly to the inactivity-timeout
// fallback instead of producing wrong answers.
func procStat(pid int) (ppid int, name, start string, err error) {
	return 0, "", "", errUnsupported
}

func bootID() (string, error) {
	return "", errUnsupported
}
```

```go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//go:build !linux && !darwin

package proclive

import (
	"os"
	"testing"
)

// On unsupported platforms liveness must degrade to Unknown (never a wrong
// Alive/Dead), so callers fall back to the inactivity timeout.
func TestCheck_UnsupportedIsUnknown(t *testing.T) {
	t.Parallel()
	id := Identity{PID: os.Getpid(), Start: "anything"}
	if got := Check(id); got != LivenessUnknown {
		    t.Errorf("Check on unsupported platform = %v, want unknown", got)
	}
	if _, ok := ResolveOwner(); ok {
		    t.Errorf("ResolveOwner on unsupported platform returned ok=true, want false")
	}
}
```

```go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Package proclive captures a process's identity (PID plus a start-time
// fingerprint) and later reports whether that exact process is still alive.
//
// It exists to detect agent sessions left in an ACTIVE state when the owning
// process went away — a clean exit, a crash, a kill, a closed terminal, or a
// reboot — without firing a SessionStop hook. Recording the owner's identity at
// turn start lets `entire status` / `entire doctor` notice the process is gone
// immediately, instead of waiting out a coarse inactivity timeout.
//
// This package is a leaf: it imports only the standard library and
// golang.org/x/sys/unix. It must NOT import session, strategy, agent, or cli,
// so those packages can depend on it without an import cycle.
package proclive

import (
	"errors"
	"os"
	"strings"
)

// Liveness is the result of checking a recorded process Identity.
type Liveness int

const (
	// LivenessUnknown means liveness could not be determined: the identity is
	// empty, was recorded on another host, or the platform cannot introspect
	// processes. Callers should fall back to a time-based heuristic.
	LivenessUnknown Liveness = iota
	// LivenessAlive means the recorded process is still running.
	LivenessAlive
	// LivenessDead means the recorded process is gone (exited, killed, or the
	// machine rebooted) or its PID has been reused by a different process.
	LivenessDead
)

func (l Liveness) String() string {
	switch l {
	case LivenessAlive:
		return "alive"
	case LivenessDead:
		return "dead"
	case LivenessUnknown:
		return "unknown"
	default:
		return "unknown"
	}
}

// Identity fingerprints the process that owns a session turn. It is persisted
// in session state and later passed to Check. The zero value means "no owner
// recorded" and always yields LivenessUnknown.
type Identity struct {
	// PID is the operating-system process id of the owner.
	PID int `json:"pid"`
	// Start is an opaque, per-platform process start-time fingerprint. It need
	// only be stable for the process lifetime and distinct across PID reuse
	// within a single boot; the Boot guard invalidates it across reboots.
	Start string `json:"start"`
	// Boot identifies the current OS boot. A mismatch at check time means the
	// machine rebooted, so the recorded PID cannot still be the same process.
	Boot string `json:"boot,omitempty"`
	// Host is the hostname where the identity was recorded. PIDs are only
	// meaningful on their own machine, so a mismatch yields Unknown.
	Host string `json:"host,omitempty"`
	// Name is the owning process's executable name (comm). Diagnostic only.
	Name string `json:"name,omitempty"`
}

var (
	// errProcessGone is returned by procStat when no process with the given PID
	// exists. Check maps it to LivenessDead.
	errProcessGone = errors.New("proclive: process not found")
	// errUnsupported is returned by the per-platform seam when the OS cannot be
	// introspected (e.g. Windows). Check maps it to LivenessUnknown.
	errUnsupported = errors.New("proclive: unsupported platform")
)

// maxAncestorDepth bounds the ResolveOwner walk so a pathological or cyclic
// process tree can never loop or hang.
const maxAncestorDepth = 12

// transientNames are process names that are never the long-lived session owner:
// our own hook binary, the shells agents commonly use to exec hooks, and the Go
// toolchain (local-dev runs hooks via `go run`, whose short-lived `go` parent
// would otherwise be recorded as the owner and exit immediately). The walk skips
// past these to reach the real agent process. Note that interpreter runtimes
// (node, bun, python) are deliberately absent — for several agents the runtime
// IS the long-lived agent, so treating it as transient would skip the real owner.
var transientNames = map[string]bool{
	"entire": true,
	"sh":     true,
	"bash":   true,
	"zsh":    true,
	"dash":   true,
	"fish":   true,
	"ash":    true,
	"ksh":    true,
	"env":    true,
	"go":     true,
}

func isTransient(name string) bool {
	return transientNames[strings.ToLower(strings.TrimSpace(name))]
}

// ResolveOwner walks up the process tree from the current process and returns
// the Identity of the first ancestor that is not our own hook binary or a
// shell — i.e. the long-lived agent that owns this session.
//
// It returns (zero, false) when no such ancestor can be determined: an
// unsupported platform, a truncated/looping tree, or only transient ancestors.
// In that case the caller should record no owner and let liveness degrade to
// the time-based fallback. Resolving to nothing is always safer than recording
// a guessed PID, which could later be (mis)read as a live or dead owner.
func ResolveOwner() (Identity, bool) {
	// The host guard is essential — a PID is only meaningful on the machine that
	// recorded it — so if the hostname can't be determined, record no owner
	// rather than an unguarded one that Check could later (mis)classify as
	// alive/dead across machines. Boot is a best-effort secondary guard; an
	// empty value just disables it (darwin records none — see bootID there).
	host, err := os.Hostname()
	if err != nil || host == "" {
		return Identity{}, false
	}
	boot, err := bootID()
	if err != nil {
		boot = ""
	}

// Walk up from our own process, reading each ancestor exactly once: procStat
	// returns its parent (to continue the walk), its name (to skip shells and our
	// own binary), and its start fingerprint (to record).
	candidate, _, _, err := procStat(os.Getpid())
	if err != nil {
		return Identity{}, false
	}
	for range maxAncestorDepth {
		if candidate <= 1 {
			return Identity{}, false
		}
		parent, name, start, err := procStat(candidate)
		if err != nil {
			return Identity{}, false
		}
		if !isTransient(name) {
			return Identity{PID: candidate, Start: start, Boot: boot, Host: host, Name: name}, true
		}
		candidate = parent
	}
	return Identity{}, false
}

// Check reports whether the process recorded in id is still alive.
//
// Precedence: an empty identity or a host mismatch is Unknown (cannot judge); a
// boot mismatch means a reboot, so the process is Dead; a missing PID or a
// start-fingerprint mismatch (PID reuse) is Dead; otherwise Alive. An
// unsupported platform is always Unknown so callers fall back to a timeout.
func Check(id Identity) Liveness {
	if id.PID <= 0 {
		return LivenessUnknown
	}
	if id.Host != "" {
		// Can't confirm we're on the recording host → can't trust its PIDs.
		host, err := os.Hostname()
		if err != nil || host != id.Host {
			return LivenessUnknown
		}
	}
	if id.Boot != "" {
		boot, err := bootID()
		switch {
		case err != nil || boot == "":
			return LivenessUnknown // can't confirm the boot → can't trust the PID
		case boot != id.Boot:
			return LivenessDead // rebooted: the process cannot have survived
		}
	}

_, _, start, err := procStat(id.PID)
	switch {
	case errors.Is(err, errUnsupported):
		return LivenessUnknown
	case errors.Is(err, errProcessGone):
		return LivenessDead
	case err != nil:
		// Transient/unexpected error: don't claim the process is dead.
		return LivenessUnknown
	}
	if id.Start != "" && start != "" && id.Start != start {
		// Same PID, different start time: the PID was reused by another process.
		return LivenessDead
	}
	return LivenessAlive
}
```

```go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
//go:build linux || darwin

package proclive

import (
	"os"
	"os/exec"
	"testing"
)

// startSleeper spawns a real long-lived child bound to the test context (so it
// is killed when the test ends) and returns its PID and a captured Identity.
func startSleeper(t *testing.T) (int, Identity) {
	t.Helper()
	cmd := exec.CommandContext(t.Context(), "sleep", "30")
	if err := cmd.Start(); err != nil {
		t.Fatalf("start sleep: %v", err)
	}
	t.Cleanup(func() {
		// The context kill (on test end) signals the process; reap it here to
		// avoid a zombie. A non-nil "signal: killed" error is expected.
		if err := cmd.Wait(); err != nil {
			t.Logf("sleeper wait: %v", err)
		}
	})
	pid := cmd.Process.Pid
	_, name, start, err := procStat(pid)
	if err != nil {
		t.Fatalf("procStat(child %d): %v", pid, err)
	}
	return pid, Identity{PID: pid, Start: start, Name: name}
}

func TestCheck_LiveProcessIsAlive(t *testing.T) {
	t.Parallel()
	_, id := startSleeper(t)
	if got := Check(id); got != LivenessAlive {
		t.Errorf("Check(live) = %v, want alive", got)
	}
}

func TestCheck_ExitedProcessIsDead(t *testing.T) {
	t.Parallel()
	cmd := exec.CommandContext(t.Context(), "sleep", "30")
	if err := cmd.Start(); err != nil {
		t.Fatalf("start sleep: %v", err)
	}
	pid := cmd.Process.Pid
	_, name, start, err := procStat(pid)
	if err != nil {
		t.Fatalf("procStat(child %d): %v", pid, err)
	}
	id := Identity{PID: pid, Start: start, Name: name}

// Kill and reap, then the recorded identity must read as dead. (A PID reused
	// within the test window would mismatch Start and still be Dead.)
	if err := cmd.Process.Kill(); err != nil {
		t.Fatalf("kill: %v", err)
	}
	if err := cmd.Wait(); err != nil {
		t.Logf("wait after kill: %v", err) // expected: "signal: killed"
	}

if got := Check(id); got != LivenessDead {
		t.Errorf("Check(exited) = %v, want dead", got)
	}
}

func TestCheck_StartMismatchIsDead(t *testing.T) {
	t.Parallel()
	// Our own process is alive, but a bogus start fingerprint must read as PID
	// reuse → Dead.
	id := Identity{PID: os.Getpid(), Start: "0.000000-not-a-real-fingerprint"}
	if got := Check(id); got != LivenessDead {
		t.Errorf("Check(start mismatch) = %v, want dead", got)
	}
}

func TestProcStat_Self(t *testing.T) {
	t.Parallel()
	ppid, name, start, err := procStat(os.Getpid())
	if err != nil {
		t.Fatalf("procStat(self): %v", err)
	}
	if ppid <= 0 {
		t.Errorf("ppid = %d, want > 0", ppid)
	}
	if name == "" {
		t.Errorf("name is empty")
	}
	if start == "" {
		t.Errorf("start is empty")
	}
}

func TestResolveOwner_ReturnsSomething(t *testing.T) {
	t.Parallel()
	// Under `go test` the ancestor chain (test binary ← go ← shell ← ...) should
	// resolve to some non-shell owner. We can't assert which, but if it resolves
	// it must be self-consistent and currently alive.
	id, ok := ResolveOwner()
	if !ok {
		t.Skip("no stable owner resolved in this environment")
	}
	if id.PID <= 0 {
		t.Errorf("resolved PID = %d, want > 0", id.PID)
	}
	if got := Check(id); got != LivenessAlive {
		t.Errorf("resolved owner Check = %v, want alive", got)
	}
}
```
