feat(v0.5.3): InspectLease four-state classifier

This commit is contained in:
2026-05-08 13:48:37 -04:00
parent 120dc54337
commit 1ab1cda111
2 changed files with 172 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
package session
import (
"errors"
"os"
"time"
)
// LeaseState classifies the lease found at a workspace's session.json. It is
// the input to the persistent-mode dispatcher: missing/stale/remote -> adopt;
// fresh local -> passive reattach.
type LeaseState int
const (
// LeaseStateNone: lease file missing or unparseable.
LeaseStateNone LeaseState = iota
// LeaseStateFreshLocal: lease parses, last_heartbeat_at < StaleLeaseAfter,
// hostname matches the current host.
LeaseStateFreshLocal
// LeaseStateStale: lease parses, last_heartbeat_at >= StaleLeaseAfter.
LeaseStateStale
// LeaseStateFreshRemote: lease parses, last_heartbeat_at < StaleLeaseAfter,
// hostname differs from the current host.
LeaseStateFreshRemote
)
func (s LeaseState) String() string {
switch s {
case LeaseStateNone:
return "none"
case LeaseStateFreshLocal:
return "fresh_local"
case LeaseStateStale:
return "stale"
case LeaseStateFreshRemote:
return "fresh_remote"
default:
return "unknown"
}
}
// InspectLease reads <wsDir>/.ctask/session.json and classifies the result.
// Reuses the existing 60-second freshness threshold (StaleLeaseAfter) — the
// persistent-mode dispatcher must agree with v0.4 lease semantics.
func InspectLease(wsDir string) LeaseState {
l, err := ReadLease(LeasePath(wsDir))
if err != nil {
// Missing or corrupt -> none. (CleanupStaleLease handles removal of
// corrupt files when sessions actually start; for a read-only
// inspection we just classify and return.)
if errors.Is(err, os.ErrNotExist) {
return LeaseStateNone
}
return LeaseStateNone
}
if l == nil {
return LeaseStateNone
}
if !IsFresh(l, time.Now(), StaleLeaseAfter) {
return LeaseStateStale
}
if l.Hostname != currentHostname() {
return LeaseStateFreshRemote
}
return LeaseStateFreshLocal
}
+106
View File
@@ -0,0 +1,106 @@
package session
import (
"os"
"path/filepath"
"testing"
"time"
)
func writeLeaseAt(t *testing.T, wsDir string, l *Lease) {
t.Helper()
if err := WriteLease(LeasePath(wsDir), l); err != nil {
t.Fatalf("WriteLease: %v", err)
}
}
func TestInspectLeaseNoneWhenMissing(t *testing.T) {
ws := t.TempDir()
if got := InspectLease(ws); got != LeaseStateNone {
t.Errorf("missing: got %v, want %v", got, LeaseStateNone)
}
}
func TestInspectLeaseNoneWhenCorrupt(t *testing.T) {
ws := t.TempDir()
if err := os.MkdirAll(filepath.Join(ws, ".ctask"), 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(LeasePath(ws), []byte("not json"), 0644); err != nil {
t.Fatalf("write: %v", err)
}
if got := InspectLease(ws); got != LeaseStateNone {
t.Errorf("corrupt: got %v, want %v", got, LeaseStateNone)
}
}
func TestInspectLeaseFreshLocal(t *testing.T) {
ws := t.TempDir()
host := currentHostname()
l := &Lease{
SessionID: "test",
PID: os.Getpid(),
Hostname: host,
Username: "u",
Agent: "claude",
Mode: "local",
StartedAt: time.Now().UTC(),
LastHeartbeatAt: time.Now().UTC(),
Terminal: "test",
}
writeLeaseAt(t, ws, l)
if got := InspectLease(ws); got != LeaseStateFreshLocal {
t.Errorf("fresh local: got %v, want %v", got, LeaseStateFreshLocal)
}
}
func TestInspectLeaseStale(t *testing.T) {
ws := t.TempDir()
host := currentHostname()
l := &Lease{
SessionID: "test",
PID: os.Getpid(),
Hostname: host,
Username: "u",
Agent: "claude",
Mode: "local",
StartedAt: time.Now().UTC().Add(-10 * time.Minute),
LastHeartbeatAt: time.Now().UTC().Add(-10 * time.Minute),
Terminal: "test",
}
writeLeaseAt(t, ws, l)
if got := InspectLease(ws); got != LeaseStateStale {
t.Errorf("stale: got %v, want %v", got, LeaseStateStale)
}
}
func TestInspectLeaseFreshRemote(t *testing.T) {
ws := t.TempDir()
other := "some-other-host-that-is-not-this-one"
if other == currentHostname() {
other = "different-" + other
}
l := &Lease{
SessionID: "test",
PID: 1,
Hostname: other,
Username: "u",
Agent: "claude",
Mode: "local",
StartedAt: time.Now().UTC(),
LastHeartbeatAt: time.Now().UTC(),
Terminal: "test",
}
writeLeaseAt(t, ws, l)
if got := InspectLease(ws); got != LeaseStateFreshRemote {
t.Errorf("fresh remote: got %v, want %v", got, LeaseStateFreshRemote)
}
}
func TestInspectLeaseStringerCoverage(t *testing.T) {
for _, s := range []LeaseState{LeaseStateNone, LeaseStateFreshLocal, LeaseStateStale, LeaseStateFreshRemote} {
if s.String() == "" {
t.Errorf("LeaseState %d has empty String()", s)
}
}
}