Timing changes for more relaxed poke intervals: - Daemon heartbeat: 60s → 5 minutes - Backoff base: 60s → 5 minutes - Backoff max: 10m → 30 minutes - Fresh threshold: <2min → <5min - Stale threshold: 2-5min → 5-15min - Very stale threshold: >5min → >15min New command: - `gt deacon heartbeat [action]` - Touch heartbeat file easily Template rewrite: - Clearer wake/sleep model - Documents wake sources (daemon poke, mail, timer callbacks) - Simpler rounds with `gt deacon heartbeat` instead of bash echo - Mentions plugins as optional maintenance tasks - Explains timer callbacks pattern 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
382 lines
8.1 KiB
Go
382 lines
8.1 KiB
Go
package deacon
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestHeartbeatFile(t *testing.T) {
|
|
townRoot := "/tmp/test-town"
|
|
expected := filepath.Join(townRoot, "deacon", "heartbeat.json")
|
|
|
|
result := HeartbeatFile(townRoot)
|
|
if result != expected {
|
|
t.Errorf("HeartbeatFile() = %q, want %q", result, expected)
|
|
}
|
|
}
|
|
|
|
func TestWriteReadHeartbeat(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "deacon-test-*")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
|
|
|
hb := &Heartbeat{
|
|
Timestamp: time.Now().UTC(),
|
|
Cycle: 42,
|
|
LastAction: "health check",
|
|
HealthyAgents: 3,
|
|
UnhealthyAgents: 1,
|
|
}
|
|
|
|
// Write heartbeat
|
|
if err := WriteHeartbeat(tmpDir, hb); err != nil {
|
|
t.Fatalf("WriteHeartbeat error: %v", err)
|
|
}
|
|
|
|
// Verify file exists
|
|
hbFile := HeartbeatFile(tmpDir)
|
|
if _, err := os.Stat(hbFile); err != nil {
|
|
t.Errorf("heartbeat file not created: %v", err)
|
|
}
|
|
|
|
// Read heartbeat
|
|
loaded := ReadHeartbeat(tmpDir)
|
|
if loaded == nil {
|
|
t.Fatal("ReadHeartbeat returned nil")
|
|
}
|
|
|
|
if loaded.Cycle != 42 {
|
|
t.Errorf("Cycle = %d, want 42", loaded.Cycle)
|
|
}
|
|
if loaded.LastAction != "health check" {
|
|
t.Errorf("LastAction = %q, want 'health check'", loaded.LastAction)
|
|
}
|
|
if loaded.HealthyAgents != 3 {
|
|
t.Errorf("HealthyAgents = %d, want 3", loaded.HealthyAgents)
|
|
}
|
|
if loaded.UnhealthyAgents != 1 {
|
|
t.Errorf("UnhealthyAgents = %d, want 1", loaded.UnhealthyAgents)
|
|
}
|
|
}
|
|
|
|
func TestReadHeartbeat_NonExistent(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "deacon-test-*")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
|
|
|
// Read from non-existent file
|
|
hb := ReadHeartbeat(tmpDir)
|
|
if hb != nil {
|
|
t.Error("expected nil for non-existent heartbeat")
|
|
}
|
|
}
|
|
|
|
func TestHeartbeat_Age(t *testing.T) {
|
|
// Test nil heartbeat
|
|
var nilHb *Heartbeat
|
|
if nilHb.Age() < 24*time.Hour {
|
|
t.Error("nil heartbeat should have very large age")
|
|
}
|
|
|
|
// Test recent heartbeat
|
|
hb := &Heartbeat{
|
|
Timestamp: time.Now().Add(-30 * time.Second),
|
|
}
|
|
if hb.Age() > time.Minute {
|
|
t.Errorf("Age() = %v, expected < 1 minute", hb.Age())
|
|
}
|
|
}
|
|
|
|
func TestHeartbeat_IsFresh(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
hb *Heartbeat
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "nil heartbeat",
|
|
hb: nil,
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "just now",
|
|
hb: &Heartbeat{
|
|
Timestamp: time.Now(),
|
|
},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "3 minutes old",
|
|
hb: &Heartbeat{
|
|
Timestamp: time.Now().Add(-3 * time.Minute),
|
|
},
|
|
expected: true, // Fresh is <5 minutes
|
|
},
|
|
{
|
|
name: "6 minutes old",
|
|
hb: &Heartbeat{
|
|
Timestamp: time.Now().Add(-6 * time.Minute),
|
|
},
|
|
expected: false, // Not fresh (>=5 minutes)
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result := tc.hb.IsFresh()
|
|
if result != tc.expected {
|
|
t.Errorf("IsFresh() = %v, want %v", result, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHeartbeat_IsStale(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
hb *Heartbeat
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "nil heartbeat",
|
|
hb: nil,
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "3 minutes old",
|
|
hb: &Heartbeat{
|
|
Timestamp: time.Now().Add(-3 * time.Minute),
|
|
},
|
|
expected: false, // Fresh (<5 minutes)
|
|
},
|
|
{
|
|
name: "7 minutes old",
|
|
hb: &Heartbeat{
|
|
Timestamp: time.Now().Add(-7 * time.Minute),
|
|
},
|
|
expected: true, // Stale (5-15 minutes)
|
|
},
|
|
{
|
|
name: "16 minutes old",
|
|
hb: &Heartbeat{
|
|
Timestamp: time.Now().Add(-16 * time.Minute),
|
|
},
|
|
expected: false, // Very stale, not stale (>15 minutes)
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result := tc.hb.IsStale()
|
|
if result != tc.expected {
|
|
t.Errorf("IsStale() = %v, want %v", result, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHeartbeat_IsVeryStale(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
hb *Heartbeat
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "nil heartbeat",
|
|
hb: nil,
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "3 minutes old",
|
|
hb: &Heartbeat{
|
|
Timestamp: time.Now().Add(-3 * time.Minute),
|
|
},
|
|
expected: false, // Fresh
|
|
},
|
|
{
|
|
name: "10 minutes old",
|
|
hb: &Heartbeat{
|
|
Timestamp: time.Now().Add(-10 * time.Minute),
|
|
},
|
|
expected: false, // Stale but not very stale
|
|
},
|
|
{
|
|
name: "16 minutes old",
|
|
hb: &Heartbeat{
|
|
Timestamp: time.Now().Add(-16 * time.Minute),
|
|
},
|
|
expected: true, // Very stale (>15 minutes)
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result := tc.hb.IsVeryStale()
|
|
if result != tc.expected {
|
|
t.Errorf("IsVeryStale() = %v, want %v", result, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHeartbeat_ShouldPoke(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
hb *Heartbeat
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "nil heartbeat - should poke",
|
|
hb: nil,
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "fresh - no poke",
|
|
hb: &Heartbeat{
|
|
Timestamp: time.Now(),
|
|
},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "stale - no poke",
|
|
hb: &Heartbeat{
|
|
Timestamp: time.Now().Add(-10 * time.Minute),
|
|
},
|
|
expected: false, // Stale (5-15 min) but not very stale
|
|
},
|
|
{
|
|
name: "very stale - should poke",
|
|
hb: &Heartbeat{
|
|
Timestamp: time.Now().Add(-16 * time.Minute),
|
|
},
|
|
expected: true, // Very stale (>15 min)
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result := tc.hb.ShouldPoke()
|
|
if result != tc.expected {
|
|
t.Errorf("ShouldPoke() = %v, want %v", result, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTouch(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "deacon-test-*")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
|
|
|
// First touch
|
|
if err := Touch(tmpDir); err != nil {
|
|
t.Fatalf("Touch error: %v", err)
|
|
}
|
|
|
|
hb := ReadHeartbeat(tmpDir)
|
|
if hb == nil {
|
|
t.Fatal("expected heartbeat after Touch")
|
|
}
|
|
if hb.Cycle != 1 {
|
|
t.Errorf("first Touch: Cycle = %d, want 1", hb.Cycle)
|
|
}
|
|
|
|
// Second touch should increment cycle
|
|
if err := Touch(tmpDir); err != nil {
|
|
t.Fatalf("Touch error: %v", err)
|
|
}
|
|
|
|
hb = ReadHeartbeat(tmpDir)
|
|
if hb.Cycle != 2 {
|
|
t.Errorf("second Touch: Cycle = %d, want 2", hb.Cycle)
|
|
}
|
|
}
|
|
|
|
func TestTouchWithAction(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "deacon-test-*")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
|
|
|
if err := TouchWithAction(tmpDir, "health scan", 5, 2); err != nil {
|
|
t.Fatalf("TouchWithAction error: %v", err)
|
|
}
|
|
|
|
hb := ReadHeartbeat(tmpDir)
|
|
if hb == nil {
|
|
t.Fatal("expected heartbeat after TouchWithAction")
|
|
}
|
|
if hb.Cycle != 1 {
|
|
t.Errorf("Cycle = %d, want 1", hb.Cycle)
|
|
}
|
|
if hb.LastAction != "health scan" {
|
|
t.Errorf("LastAction = %q, want 'health scan'", hb.LastAction)
|
|
}
|
|
if hb.HealthyAgents != 5 {
|
|
t.Errorf("HealthyAgents = %d, want 5", hb.HealthyAgents)
|
|
}
|
|
if hb.UnhealthyAgents != 2 {
|
|
t.Errorf("UnhealthyAgents = %d, want 2", hb.UnhealthyAgents)
|
|
}
|
|
}
|
|
|
|
func TestWriteHeartbeat_CreatesDirectory(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "deacon-test-*")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
|
|
|
// Ensure deacon directory doesn't exist
|
|
deaconDir := filepath.Join(tmpDir, "deacon")
|
|
if _, err := os.Stat(deaconDir); !os.IsNotExist(err) {
|
|
t.Fatal("deacon directory should not exist initially")
|
|
}
|
|
|
|
// Write heartbeat should create directory
|
|
hb := &Heartbeat{Cycle: 1}
|
|
if err := WriteHeartbeat(tmpDir, hb); err != nil {
|
|
t.Fatalf("WriteHeartbeat error: %v", err)
|
|
}
|
|
|
|
// Verify directory was created
|
|
if _, err := os.Stat(deaconDir); err != nil {
|
|
t.Errorf("deacon directory should exist: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWriteHeartbeat_SetsTimestamp(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "deacon-test-*")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
|
|
|
// Write heartbeat without timestamp
|
|
hb := &Heartbeat{Cycle: 1}
|
|
if err := WriteHeartbeat(tmpDir, hb); err != nil {
|
|
t.Fatalf("WriteHeartbeat error: %v", err)
|
|
}
|
|
|
|
// Read back and verify timestamp was set
|
|
loaded := ReadHeartbeat(tmpDir)
|
|
if loaded == nil {
|
|
t.Fatal("expected heartbeat")
|
|
}
|
|
if loaded.Timestamp.IsZero() {
|
|
t.Error("expected Timestamp to be set")
|
|
}
|
|
if time.Since(loaded.Timestamp) > time.Minute {
|
|
t.Error("Timestamp should be recent")
|
|
}
|
|
}
|