session: record owning process and detect exited sessions · Entire
session: record owning process and detect exited sessions
d09e269→main·
Soph·3w ago·3 files·+110 added/-0 removed
State.Owner stores the proclive.Identity captured at turn start. OwnerLiveness/OwnerExited report when an ACTIVE session's agent process has gone away (clean exit, crash, kill, terminal close, reboot) without a SessionStop hook firing, falling back to the StuckActiveThreshold timeout when liveness is Unknown (no owner, cross-host, unsupported platform).
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
Sessions
f99451d13eb9View transcript
Changes
3
cmd/entire/cli/session
Aowner_live_test.go+37
Aowner_test.go+38
Mstate.go+35
//go:build linux || darwin
package session
import (
"os"
"testing"
"github.com/entireio/cli/cmd/entire/cli/proclive"
)
func TestOwnerExited_DeadOwnerActiveIsTrue(t *testing.T) {
t.Parallel()
// Our own PID is alive, but a mismatched start fingerprint makes proclive
// treat it as a reused (dead) PID — a deterministic "owner gone" signal.
exitedOwner := &proclive.Identity{PID: os.Getpid(), Start: "bogus-start-fingerprint"}
s := &State{Phase: PhaseActive, Owner: exitedOwner}
if s.OwnerLiveness() != proclive.LivenessDead {
t.Fatalf("OwnerLiveness = %v, want dead", s.OwnerLiveness())
}
if !s.OwnerExited() {
error("OwnerExited(active, dead owner) = false, want true")
}
}
func TestOwnerExited_LiveOwnerActiveIsFalse(t *testing.T) {
t.Parallel()
// A faithfully-captured identity of a live process must NOT read as exited.
id, ok := proclive.ResolveOwner()
if !ok {
Skip("no stable owner resolved in this environment")
}
s := &State{Phase: PhaseActive, Owner: &id}
if s.OwnerExited() {
error("OwnerExited(active, live owner) = true, want false")
}
}
Acmd/entire/cli/session/owner_live_test.go+37
package session
import (
"testing"
"github.com/entireio/cli/cmd/entire/cli/proclive"
)
func TestOwnerLiveness_NilOwnerIsUnknown(t *testing.T) {
t.Parallel()
s := &State{Phase: PhaseActive}
if got := s.OwnerLiveness(); got != proclive.LivenessUnknown {
t.Errorf("OwnerLiveness(nil owner) = %v, want unknown", got)
}
}
func TestOwnerExited_NilOwnerIsFalse(t *testing.T) {
t.Parallel()
// No owner recorded: behavior must degrade to the timeout heuristic, so
// OwnerExited reports false regardless of phase.
s := &State{Phase: PhaseActive}
if s.OwnerExited() {
error("OwnerExited(nil owner) = true, want false")
}
}
func TestOwnerExited_NonActivePhaseIsFalse(t *testing.T) {
t.Parallel()
// Even with a dead owner, a non-ACTIVE session is not "exited" — there's no
// live turn to have been orphaned.
deadOwner := &proclive.Identity{PID: 999999999, Start: "never"}
for _, phase := range []Phase{PhaseIdle, PhaseEnded} {
s := &State{Phase: phase, Owner: deadOwner}
if s.OwnerExited() {
error("OwnerExited(phase=%s) = true, want false", phase)
}
}
}
Acmd/entire/cli/session/owner_test.go+38
"github.com/entireio/cli/cmd/entire/cli/jsonutil"
"github.com/entireio/cli/cmd/entire/cli/logging"
"github.com/entireio/cli/cmd/entire/cli/osroot"
"github.com/entireio/cli/cmd/entire/cli/proclive"
"github.com/entireio/cli/cmd/entire/cli/validation"
// PendingPromptAttribution holds attribution calculated at prompt start (before agent runs).
// This is moved to PromptAttributions when SaveStep is called.
PendingPromptAttribution *PromptAttribution `json:"pending_prompt_attribution,omitempty"`
// Owner fingerprints the process that owns this session's agent turn,
// captured at each turn start via proclive.ResolveOwner. It lets liveness
// checks detect an ACTIVE session whose agent has exited (clean /exit,
// crash, kill, terminal close, reboot) without a SessionStop hook firing —
// see OwnerExited. nil for legacy sessions or when the owner couldn't be
// resolved, in which case liveness falls back to the StuckActiveThreshold
// timeout. Only meaningful on Owner.Host.
Owner *proclive.Identity `json:"owner,omitempty"`
}
// PromptAttribution captures line-level attribution data at the start of each prompt.
// OwnerLiveness reports the liveness of this session's recorded owner process.
// It returns proclive.LivenessUnknown when no owner was recorded (legacy
// sessions, or sessions where the owner couldn't be resolved), so callers can
// fall back to the time-based IsStuckActive heuristic.
func (s *State) OwnerLiveness() proclive.Liveness {
if s.Owner == nil {
return proclive.LivenessUnknown
}
return proclive.Check(*s.Owner)
}
// OwnerExited reports true when this session is ACTIVE but its owning agent
// process is gone — exited cleanly, crashed, was killed, or the machine
// rebooted — without a SessionStop hook firing. Unlike IsStuckActive (a
// time-based heuristic), this is detected immediately, regardless of how
// recently the session interacted. It returns false when liveness is Unknown
// (no owner recorded, cross-host state, or an unsupported platform) so behavior
// degrades to the StuckActiveThreshold timeout.
func (s *State) OwnerExited() bool {
if !s.Phase.IsActive() {
return false
}
return s.OwnerLiveness() == proclive.LivenessDead
}
func (s *State) IsStale() bool {
var since time.Duration
if s.LastInteractionTime != nil {