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
cmd/entire/cli/session
Aowner_live_test.go+37
Aowner_test.go+38
Mstate.go+35
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 {