feat(v0.6): route lease-freshness callsites through IsStale

InspectLease, CleanupStaleLease, runActiveLeaseCheck, and statusAt now
use the PID-aware IsStale predicate. A dead local owner PID makes a lease
stale immediately; SessionStatus / list / info reflect this with no
display-code change.

Corrects three cmd-package session-display test fixtures that built
"active" leases with the local hostname but synthetic PIDs — now that
freshness is PID-aware, an active session must be owned by a live
process, so the fixtures use os.Getpid().
This commit is contained in:
2026-05-15 14:29:50 -04:00
parent f379a6d059
commit d575ddd0f5
7 changed files with 70 additions and 10 deletions
+10 -3
View File
@@ -2,6 +2,7 @@ package cmd
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
@@ -61,8 +62,12 @@ func TestInfoShowsActivePersistentSessionWithAttachHint(t *testing.T) {
wsDir := makeInfoSessionWorkspace(t, root, "active-persist")
now := time.Now().UTC().Truncate(time.Second)
// An active session is owned by a live process: lease freshness is
// PID-aware (v0.6 Phase 3), so a local-hostname lease must point at a
// live PID to read as active. Use the test process itself.
livePID := os.Getpid()
writeLeaseAtForCmdTest(t, wsDir, &session.Lease{
PID: 12345,
PID: livePID,
Hostname: session.CurrentHostname(),
Mode: "persistent",
StartedAt: now,
@@ -76,7 +81,7 @@ func TestInfoShowsActivePersistentSessionWithAttachHint(t *testing.T) {
for _, want := range []string{
"Session: active",
"Mode: persistent",
"Owner: pid 12345", // local host -> hostname omitted
fmt.Sprintf("Owner: pid %d", livePID), // local host -> hostname omitted
"Attach: ctask attach active-persist",
} {
if !strings.Contains(out, want) {
@@ -91,8 +96,10 @@ func TestInfoShowsActiveDirectSessionWithoutAttachHint(t *testing.T) {
wsDir := makeInfoSessionWorkspace(t, root, "active-direct")
now := time.Now().UTC().Truncate(time.Second)
// Live PID required for an active local-hostname lease — see
// TestInfoShowsActivePersistentSessionWithAttachHint.
writeLeaseAtForCmdTest(t, wsDir, &session.Lease{
PID: 8888,
PID: os.Getpid(),
Hostname: session.CurrentHostname(),
Mode: "direct",
StartedAt: now,
+11 -3
View File
@@ -47,17 +47,25 @@ func TestListSessionColumnShowsModeAndStaleAndDash(t *testing.T) {
staleWS := makeListSessionWorkspace(t, root, "general", "2026-05-12_stale-ws", "stale-ws", "active")
makeListSessionWorkspace(t, root, "general", "2026-05-11_idle-ws", "idle-ws", "active")
// persist-ws and direct-ws must read as active sessions: a fresh
// heartbeat alone is not enough now that lease freshness is PID-aware
// (v0.6 Phase 3). Use the live test process PID so the local-hostname
// lease passes the PID-liveness check, mirroring
// session.TestInspectLeaseFreshLocal.
livePID := os.Getpid()
writeLeaseAtForCmdTest(t, persistWS, &session.Lease{
PID: 1, Hostname: host, Mode: "persistent",
PID: livePID, Hostname: host, Mode: "persistent",
StartedAt: now, LastHeartbeatAt: now,
})
writeLeaseAtForCmdTest(t, directWS, &session.Lease{
PID: 2, Hostname: host, Mode: "direct",
PID: livePID, Hostname: host, Mode: "direct",
StartedAt: now, LastHeartbeatAt: now,
})
// stale-ws is wall-clock stale (heartbeat 10m ago); its PID is
// irrelevant — wall-clock staleness wins unconditionally.
heartbeat := now.Add(-10 * time.Minute)
writeLeaseAtForCmdTest(t, staleWS, &session.Lease{
PID: 3, Hostname: host, Mode: "direct",
PID: livePID, Hostname: host, Mode: "direct",
StartedAt: heartbeat, LastHeartbeatAt: heartbeat,
})
+45
View File
@@ -0,0 +1,45 @@
package session
import (
"testing"
"time"
)
// A fresh-by-wall-clock local lease whose owner PID is dead must be
// classified LeaseStateStale by InspectLease, so the persistent-mode
// dispatcher routes to adoption immediately rather than after the 60s
// wall-clock wait. (dispatchPersistent itself lives in cmd/ and is
// covered there; this test pins the InspectLease half of the contract.)
func TestInspectLeaseDeadLocalPIDIsStale(t *testing.T) {
withCheckProcess(t, func(int) ProcessState { return ProcessDead })
ws := t.TempDir()
now := time.Now().UTC()
writeLeaseAt(t, ws, &Lease{
SessionID: "test",
PID: 4242,
Hostname: currentHostname(),
LastHeartbeatAt: now, // fresh by wall-clock
StartedAt: now,
})
if got := InspectLease(ws); got != LeaseStateStale {
t.Errorf("dead-PID local lease: InspectLease = %v, want LeaseStateStale", got)
}
}
// The control case: a fresh local lease with a live PID stays FreshLocal,
// so passive reattach (not adoption) is chosen.
func TestInspectLeaseLiveLocalPIDIsFreshLocal(t *testing.T) {
withCheckProcess(t, func(int) ProcessState { return ProcessAlive })
ws := t.TempDir()
now := time.Now().UTC()
writeLeaseAt(t, ws, &Lease{
SessionID: "test",
PID: 4242,
Hostname: currentHostname(),
LastHeartbeatAt: now,
StartedAt: now,
})
if got := InspectLease(ws); got != LeaseStateFreshLocal {
t.Errorf("live-PID local lease: InspectLease = %v, want LeaseStateFreshLocal", got)
}
}
+1 -1
View File
@@ -189,7 +189,7 @@ func CleanupStaleLease(path string, staleAfter time.Duration) (*Lease, error) {
}
return nil, nil
}
if IsFresh(&l, time.Now(), staleAfter) {
if !IsStale(&l, time.Now(), staleAfter) {
return nil, nil
}
if rmErr := os.Remove(path); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) {
+1 -1
View File
@@ -56,7 +56,7 @@ func InspectLease(wsDir string) LeaseState {
if l == nil {
return LeaseStateNone
}
if !IsFresh(l, time.Now(), StaleLeaseAfter) {
if IsStale(l, time.Now(), StaleLeaseAfter) {
return LeaseStateStale
}
if l.Hostname != currentHostname() {
+1 -1
View File
@@ -101,7 +101,7 @@ func runActiveLeaseCheck(opts PreflightOpts) (bool, bool, error) {
existing, err := ReadLease(leasePath)
switch {
case err == nil && existing != nil:
if IsFresh(existing, time.Now(), StaleLeaseAfter) {
if !IsStale(existing, time.Now(), StaleLeaseAfter) {
if opts.Force {
return true, true, nil
}
+1 -1
View File
@@ -74,7 +74,7 @@ func statusAt(wsDir string, now time.Time) Status {
}
state := SessionStateStale
if IsFresh(&l, now, StaleLeaseAfter) {
if !IsStale(&l, now, StaleLeaseAfter) {
state = SessionStateActive
}