From d575ddd0f5508a6576de5f7d6339ca8b1e46358a Mon Sep 17 00:00:00 2001 From: typebasedio Date: Fri, 15 May 2026 14:29:50 -0400 Subject: [PATCH] feat(v0.6): route lease-freshness callsites through IsStale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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(). --- cmd/info_session_test.go | 13 +++++-- cmd/list_session_test.go | 14 ++++++-- internal/session/adopt_pidcheck_test.go | 45 +++++++++++++++++++++++++ internal/session/lease.go | 2 +- internal/session/lease_inspect.go | 2 +- internal/session/run_preflight.go | 2 +- internal/session/status.go | 2 +- 7 files changed, 70 insertions(+), 10 deletions(-) create mode 100644 internal/session/adopt_pidcheck_test.go diff --git a/cmd/info_session_test.go b/cmd/info_session_test.go index 165a301..114baf7 100644 --- a/cmd/info_session_test.go +++ b/cmd/info_session_test.go @@ -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, diff --git a/cmd/list_session_test.go b/cmd/list_session_test.go index 5243cf8..b7691a9 100644 --- a/cmd/list_session_test.go +++ b/cmd/list_session_test.go @@ -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, }) diff --git a/internal/session/adopt_pidcheck_test.go b/internal/session/adopt_pidcheck_test.go new file mode 100644 index 0000000..73f94f9 --- /dev/null +++ b/internal/session/adopt_pidcheck_test.go @@ -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) + } +} diff --git a/internal/session/lease.go b/internal/session/lease.go index 86c0850..24e556f 100644 --- a/internal/session/lease.go +++ b/internal/session/lease.go @@ -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) { diff --git a/internal/session/lease_inspect.go b/internal/session/lease_inspect.go index 71beaca..ee915ba 100644 --- a/internal/session/lease_inspect.go +++ b/internal/session/lease_inspect.go @@ -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() { diff --git a/internal/session/run_preflight.go b/internal/session/run_preflight.go index 3fcfa1a..38540d7 100644 --- a/internal/session/run_preflight.go +++ b/internal/session/run_preflight.go @@ -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 } diff --git a/internal/session/status.go b/internal/session/status.go index 689bdc3..f8d3ac9 100644 --- a/internal/session/status.go +++ b/internal/session/status.go @@ -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 }