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

//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 {