session: record owning process and detect exited sessions · Entire

session: record owning process and detect exited sessions

2178181·

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

bbc001fd6a94View transcript

Changes

3

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

//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() {
                                    t.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 {
        t.Skip("no stable owner resolved in this environment")
    }
    s := &State{Phase: PhaseActive, Owner: &id}
    if s.OwnerExited() {
                                    t.Error("OwnerExited(active, live owner) = true, want false")
    }
}

Acmd/entire/cli/session/owner_live_test.go+37

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

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() {
                                    t.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() {
                                    t.Errorf("OwnerExited(phase=%s) = true, want false", phase)
        }
    }
}

Acmd/entire/cli/session/owner_test.go+38

17 unmodified lines

18
19
20
21
22
23
24
276 unmodified lines

301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
104 unmodified lines

420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450

17 unmodified lines

"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"
)

276 unmodified lines

// 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
timestamp. Only meaningful on Owner.Host.
    Owner *proclive.Identity `json:"owner,omitempty"`
}

// PromptAttribution captures line-level attribution data at the start of each prompt.
104 unmodified lines

return time.Since(*ref) > StuckActiveThreshold
}

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