diff --git a/internal/session/lease_inspect.go b/internal/session/lease_inspect.go new file mode 100644 index 0000000..71beaca --- /dev/null +++ b/internal/session/lease_inspect.go @@ -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 /.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 +} diff --git a/internal/session/lease_inspect_test.go b/internal/session/lease_inspect_test.go new file mode 100644 index 0000000..caa08a3 --- /dev/null +++ b/internal/session/lease_inspect_test.go @@ -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) + } + } +}