| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "reasonix/internal/config" |
| 13 | "reasonix/internal/control" |
| 14 | fileencoding "reasonix/internal/fileutil/encoding" |
| 15 | ) |
| 16 | |
| 17 | func TestHeartbeatConfigPathUsesReasonixUserStateDir(t *testing.T) { |
| 18 | isolateDesktopUserDirs(t) |
| 19 | engine := &HeartbeatEngine{} |
| 20 | want := filepath.Join(config.MemoryUserDir(), "heartbeat-tasks.json") |
| 21 | |
| 22 | if got := engine.configPath(); got != want { |
| 23 | t.Fatalf("configPath = %q, want %q", got, want) |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | func TestHeartbeatConfigRevisionKeepsLegacyFilesReadable(t *testing.T) { |
| 28 | isolateDesktopUserDirs(t) |
| 29 | engine := &HeartbeatEngine{} |
| 30 | legacy := `{"tasks":[{"id":"legacy","title":"Legacy","interval":"1h","enabled":false}]}` |
| 31 | if err := os.MkdirAll(filepath.Dir(engine.configPath()), 0o755); err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | if err := os.WriteFile(engine.configPath(), []byte(legacy), 0o644); err != nil { |
| 35 | t.Fatal(err) |
| 36 | } |
| 37 | tasks := engine.ReloadTasks() |
| 38 | if len(tasks) != 1 || tasks[0].ID != "legacy" { |
| 39 | t.Fatalf("legacy tasks = %+v, want one readable task", tasks) |
| 40 | } |
| 41 | if engine.cfgRevision != 0 { |
| 42 | t.Fatalf("legacy revision = %d, want zero", engine.cfgRevision) |
| 43 | } |
| 44 | if err := engine.ReplaceTasks(tasks); err != nil { |
| 45 | t.Fatalf("upgrade save: %v", err) |
| 46 | } |
| 47 | data, err := os.ReadFile(engine.configPath()) |
| 48 | if err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | var cfg heartbeatConfig |
| 52 | if err := json.Unmarshal(data, &cfg); err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | if cfg.Revision != 1 || len(cfg.Tasks) != 1 { |
| 56 | t.Fatalf("upgraded config = %+v, want revision 1 with legacy task", cfg) |
| 57 | } |
| 58 | var previousReader struct { |
| 59 | Tasks []HeartbeatTask `json:"tasks"` |
| 60 | } |
| 61 | if err := json.Unmarshal(data, &previousReader); err != nil || len(previousReader.Tasks) != 1 { |
| 62 | t.Fatalf("previous reader could not ignore revision: tasks=%+v err=%v", previousReader.Tasks, err) |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | func TestHeartbeatReplaceTasksRejectsStaleRevision(t *testing.T) { |
| 67 | isolateDesktopUserDirs(t) |
| 68 | engine := &HeartbeatEngine{} |
| 69 | initial := []HeartbeatTask{{ID: "same", Title: "initial", Interval: "1h", Enabled: false}} |
| 70 | if err := engine.saveTasks(initial); err != nil { |
| 71 | t.Fatal(err) |
| 72 | } |
| 73 | engine.ReloadTasks() |
| 74 | external := []HeartbeatTask{{ID: "same", Title: "edited externally", Interval: "2h", Enabled: false}} |
| 75 | if err := engine.saveTasks(external); err != nil { |
| 76 | t.Fatal(err) |
| 77 | } |
| 78 | err := engine.ReplaceTasks([]HeartbeatTask{{ID: "same", Title: "stale UI edit", Interval: "3h", Enabled: false}}) |
| 79 | if !errors.Is(err, ErrHeartbeatConfigConflict) { |
| 80 | t.Fatalf("ReplaceTasks error = %v, want config conflict", err) |
| 81 | } |
| 82 | onDisk := engine.loadTasks() |
| 83 | if len(onDisk) != 1 || onDisk[0].Title != "edited externally" || onDisk[0].Interval != "2h" { |
| 84 | t.Fatalf("stale replacement changed disk config: %+v", onDisk) |
| 85 | } |
| 86 | if got := engine.ListTasks()[0].Title; got != "initial" { |
| 87 | t.Fatalf("stale replacement changed in-memory tasks: %q", got) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | func TestHeartbeatReplaceConfigRejectsSameRevisionExternalEditByETag(t *testing.T) { |
| 92 | isolateDesktopUserDirs(t) |
| 93 | engine := &HeartbeatEngine{} |
| 94 | initial := []HeartbeatTask{{ID: "same", Title: "initial", Interval: "1h", Enabled: false}} |
| 95 | if err := engine.saveTasks(initial); err != nil { |
| 96 | t.Fatal(err) |
| 97 | } |
| 98 | loaded := engine.ReloadConfig() |
| 99 | external := heartbeatConfig{Revision: loaded.Revision, Tasks: []HeartbeatTask{{ID: "same", Title: "edited externally", Interval: "2h", Enabled: false}}} |
| 100 | data, err := json.MarshalIndent(external, "", " ") |
| 101 | if err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | if err := os.WriteFile(engine.configPath(), data, 0o644); err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | _, err = engine.ReplaceConfig(HeartbeatConfigUpdate{ |
| 108 | Revision: loaded.Revision, |
| 109 | ETag: loaded.ETag, |
| 110 | Tasks: []HeartbeatTask{{ID: "same", Title: "stale UI edit", Interval: "3h", Enabled: false}}, |
| 111 | }) |
| 112 | if !errors.Is(err, ErrHeartbeatConfigConflict) { |
| 113 | t.Fatalf("ReplaceConfig error = %v, want config conflict", err) |
| 114 | } |
| 115 | onDisk := engine.loadTasks() |
| 116 | if len(onDisk) != 1 || onDisk[0].Title != "edited externally" { |
| 117 | t.Fatalf("same-revision external edit was overwritten: %+v", onDisk) |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | func TestHeartbeatLoadTasksDecodesGB18030Config(t *testing.T) { |
| 122 | isolateDesktopUserDirs(t) |
| 123 | engine := &HeartbeatEngine{} |
| 124 | body := `{"tasks":[{"id":"daily","title":"每日检查","prompt":"总结中文状态","interval":"1h","enabled":true}]}` |
| 125 | if err := os.MkdirAll(filepath.Dir(engine.configPath()), 0o755); err != nil { |
| 126 | t.Fatal(err) |
| 127 | } |
| 128 | if err := os.WriteFile(engine.configPath(), fileencoding.Encode(body, fileencoding.GB18030), 0o644); err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | |
| 132 | tasks := engine.loadTasks() |
| 133 | if len(tasks) != 1 || tasks[0].Title != "每日检查" || tasks[0].Prompt != "总结中文状态" { |
| 134 | t.Fatalf("loadTasks = %+v, want decoded Chinese task", tasks) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | func TestHeartbeatTaskDueAtWaitsForDailySchedule(t *testing.T) { |
| 139 | loc := time.FixedZone("test", 8*60*60) |
| 140 | created := time.Date(2026, 6, 18, 8, 30, 0, 0, loc) |
| 141 | task := HeartbeatTask{ |
| 142 | ID: "daily", |
| 143 | Interval: "24h|daily@09:00", |
| 144 | Enabled: true, |
| 145 | CreatedAt: created.UnixMilli(), |
| 146 | } |
| 147 | |
| 148 | if heartbeatTaskDueAt(task, time.Date(2026, 6, 18, 8, 59, 0, 0, loc)) { |
| 149 | t.Fatal("daily task should wait for the configured clock time") |
| 150 | } |
| 151 | if !heartbeatTaskDueAt(task, time.Date(2026, 6, 18, 9, 0, 0, 0, loc)) { |
| 152 | t.Fatal("daily task should be due at the configured clock time") |
| 153 | } |
| 154 | |
| 155 | task.LastRunAt = time.Date(2026, 6, 18, 9, 0, 0, 0, loc).UnixMilli() |
| 156 | if heartbeatTaskDueAt(task, time.Date(2026, 6, 18, 10, 0, 0, 0, loc)) { |
| 157 | t.Fatal("daily task should not run twice for the same scheduled occurrence") |
| 158 | } |
| 159 | if !heartbeatTaskDueAt(task, time.Date(2026, 6, 19, 9, 0, 0, 0, loc)) { |
| 160 | t.Fatal("daily task should be due at the next scheduled occurrence") |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | func TestHeartbeatTaskDueAtCronExpression(t *testing.T) { |
| 165 | loc := time.FixedZone("test", 8*60*60) |
| 166 | task := HeartbeatTask{ |
| 167 | ID: "cron", |
| 168 | Interval: "0 9 * * 1-5", // weekdays at 09:00 |
| 169 | Enabled: true, |
| 170 | CreatedAt: time.Date(2026, 6, 15, 0, 0, 0, 0, loc).UnixMilli(), // Monday |
| 171 | } |
| 172 | |
| 173 | // Not due outside the cron window (Monday 08:59). |
| 174 | if heartbeatTaskDueAt(task, time.Date(2026, 6, 15, 8, 59, 0, 0, loc)) { |
| 175 | t.Fatal("cron task should wait for the configured time") |
| 176 | } |
| 177 | // Due exactly at Monday 09:00. |
| 178 | if !heartbeatTaskDueAt(task, time.Date(2026, 6, 15, 9, 0, 0, 0, loc)) { |
| 179 | t.Fatal("cron task should be due at the configured time") |
| 180 | } |
| 181 | // Not due again within the same minute after running. |
| 182 | task.LastRunAt = time.Date(2026, 6, 15, 9, 0, 0, 0, loc).UnixMilli() |
| 183 | if heartbeatTaskDueAt(task, time.Date(2026, 6, 15, 9, 0, 30, 0, loc)) { |
| 184 | t.Fatal("cron task should not fire twice for the same occurrence") |
| 185 | } |
| 186 | // Due again on the next weekday. |
| 187 | if !heartbeatTaskDueAt(task, time.Date(2026, 6, 16, 9, 0, 0, 0, loc)) { |
| 188 | t.Fatal("cron task should be due at the next weekday occurrence") |
| 189 | } |
| 190 | // Weekend (Saturday) is not part of 1-5. |
| 191 | if heartbeatTaskDueAt(task, time.Date(2026, 6, 20, 9, 0, 0, 0, loc)) { |
| 192 | t.Fatal("cron task should skip weekends") |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | func TestHeartbeatTaskDueAtCronEvery15Minutes(t *testing.T) { |
| 197 | loc := time.FixedZone("test", 8*60*60) |
| 198 | task := HeartbeatTask{ |
| 199 | ID: "cron-15", |
| 200 | Interval: "*/15 * * * *", |
| 201 | Enabled: true, |
| 202 | CreatedAt: time.Date(2026, 6, 18, 0, 0, 0, 0, loc).UnixMilli(), |
| 203 | } |
| 204 | |
| 205 | for _, tt := range []struct { |
| 206 | at time.Time |
| 207 | want bool |
| 208 | }{ |
| 209 | {time.Date(2026, 6, 18, 10, 7, 0, 0, loc), false}, |
| 210 | {time.Date(2026, 6, 18, 10, 15, 0, 0, loc), true}, |
| 211 | {time.Date(2026, 6, 18, 10, 30, 0, 0, loc), true}, |
| 212 | {time.Date(2026, 6, 18, 10, 31, 0, 0, loc), false}, |
| 213 | } { |
| 214 | if got := heartbeatTaskDueAt(task, tt.at); got != tt.want { |
| 215 | t.Fatalf("cron */15 due at %v = %v, want %v", tt.at, got, tt.want) |
| 216 | } |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | func TestHeartbeatTaskDueAtCronDedupesByOccurrenceMinute(t *testing.T) { |
| 221 | loc := time.UTC |
| 222 | lastRun := time.Date(2026, 6, 18, 9, 1, 41, 0, loc) |
| 223 | task := HeartbeatTask{Interval: "* * * * *", LastRunAt: lastRun.UnixMilli()} |
| 224 | |
| 225 | if heartbeatTaskDueAt(task, time.Date(2026, 6, 18, 9, 1, 59, 0, loc)) { |
| 226 | t.Fatal("cron task must not run twice in one occurrence minute") |
| 227 | } |
| 228 | if !heartbeatTaskDueAt(task, time.Date(2026, 6, 18, 9, 2, 10, 0, loc)) { |
| 229 | t.Fatal("every-minute cron task must run in the next occurrence minute") |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | func TestHeartbeatTaskDueAtHonorsWeeklySelection(t *testing.T) { |
| 234 | loc := time.UTC |
| 235 | task := HeartbeatTask{ |
| 236 | ID: "weekly", |
| 237 | Interval: "168h|weekly:fri@09:00", |
| 238 | Enabled: true, |
| 239 | CreatedAt: time.Date(2026, 6, 15, 8, 0, 0, 0, loc).UnixMilli(), |
| 240 | } |
| 241 | |
| 242 | if heartbeatTaskDueAt(task, time.Date(2026, 6, 18, 12, 0, 0, 0, loc)) { |
| 243 | t.Fatal("weekly task should not run before the selected weekday") |
| 244 | } |
| 245 | if !heartbeatTaskDueAt(task, time.Date(2026, 6, 19, 9, 0, 0, 0, loc)) { |
| 246 | t.Fatal("weekly task should run on the selected weekday and time") |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | type heartbeatStatusStub struct { |
| 251 | status control.RuntimeStatus |
| 252 | } |
| 253 | |
| 254 | func (s heartbeatStatusStub) RuntimeStatus() control.RuntimeStatus { |
| 255 | return s.status |
| 256 | } |
| 257 | |
| 258 | type heartbeatExecuteTaskCtrlStub struct { |
| 259 | stubSessionAPI |
| 260 | status control.RuntimeStatus |
| 261 | submitted []string |
| 262 | approvalMode string |
| 263 | } |
| 264 | |
| 265 | type heartbeatSignalingCtrlStub struct { |
| 266 | heartbeatExecuteTaskCtrlStub |
| 267 | submittedSignal chan struct{} |
| 268 | } |
| 269 | |
| 270 | func (s *heartbeatSignalingCtrlStub) SubmitUserTurn(input, display string) { |
| 271 | s.heartbeatExecuteTaskCtrlStub.SubmitUserTurn(input, display) |
| 272 | close(s.submittedSignal) |
| 273 | } |
| 274 | |
| 275 | func (s *heartbeatExecuteTaskCtrlStub) RuntimeStatus() control.RuntimeStatus { |
| 276 | return s.status |
| 277 | } |
| 278 | |
| 279 | func (s *heartbeatExecuteTaskCtrlStub) SubmitUserTurn(input, display string) { |
| 280 | s.submitted = append(s.submitted, input) |
| 281 | s.status.Running = true |
| 282 | } |
| 283 | |
| 284 | func (s *heartbeatExecuteTaskCtrlStub) SetToolApprovalMode(mode string) { |
| 285 | s.approvalMode = mode |
| 286 | } |
| 287 | |
| 288 | func (s *heartbeatExecuteTaskCtrlStub) PlanMode() bool { |
| 289 | return false |
| 290 | } |
| 291 | |
| 292 | func (s *heartbeatExecuteTaskCtrlStub) AutoApproveTools() bool { |
| 293 | return false |
| 294 | } |
| 295 | |
| 296 | func (s *heartbeatExecuteTaskCtrlStub) Goal() string { |
| 297 | return "" |
| 298 | } |
| 299 | |
| 300 | func (s *heartbeatExecuteTaskCtrlStub) GoalStatus() string { |
| 301 | return control.GoalStatusStopped |
| 302 | } |
| 303 | |
| 304 | func (s *heartbeatExecuteTaskCtrlStub) ToolApprovalMode() string { |
| 305 | return s.approvalMode |
| 306 | } |
| 307 | |
| 308 | func (s *heartbeatExecuteTaskCtrlStub) SetSessionPath(string) {} |
| 309 | |
| 310 | func (s *heartbeatExecuteTaskCtrlStub) SessionPath() string { |
| 311 | return "" |
| 312 | } |
| 313 | |
| 314 | func (s *heartbeatExecuteTaskCtrlStub) SessionDir() string { |
| 315 | return "" |
| 316 | } |
| 317 | |
| 318 | func (s *heartbeatExecuteTaskCtrlStub) Close() {} |
| 319 | |
| 320 | func TestHeartbeatControllerBusyIncludesPendingPrompt(t *testing.T) { |
| 321 | if heartbeatControllerBusy(heartbeatStatusStub{status: control.RuntimeStatus{Running: false, PendingPrompt: false}}) { |
| 322 | t.Fatal("idle controller should be available for heartbeat execution") |
| 323 | } |
| 324 | if !heartbeatControllerBusy(heartbeatStatusStub{status: control.RuntimeStatus{Running: true}}) { |
| 325 | t.Fatal("running controller should be busy") |
| 326 | } |
| 327 | if !heartbeatControllerBusy(heartbeatStatusStub{status: control.RuntimeStatus{PendingPrompt: true}}) { |
| 328 | t.Fatal("pending prompt should keep controller busy") |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | func TestHeartbeatTaskExecutionReservationSerializesTriggers(t *testing.T) { |
| 333 | engine := &HeartbeatEngine{} |
| 334 | if !engine.claimTask("same") { |
| 335 | t.Fatal("first task claim should succeed") |
| 336 | } |
| 337 | second := make(chan bool, 1) |
| 338 | go func() { second <- engine.claimTask("same") }() |
| 339 | if <-second { |
| 340 | t.Fatal("overlapping task trigger should be rejected") |
| 341 | } |
| 342 | engine.releaseTask("same") |
| 343 | if !engine.claimTask("same") { |
| 344 | t.Fatal("task should be claimable after the owner releases it") |
| 345 | } |
| 346 | engine.releaseTask("same") |
| 347 | } |
| 348 | |
| 349 | func TestHeartbeatExecuteTaskPersistsFreshConversationTopicID(t *testing.T) { |
| 350 | isolateDesktopUserDirs(t) |
| 351 | app := NewApp() |
| 352 | app.ctx = context.Background() |
| 353 | app.readyHook = func() {} |
| 354 | app.runtimeEvents.emit = func(context.Context, string, ...any) {} |
| 355 | engine := &HeartbeatEngine{ |
| 356 | app: app, |
| 357 | pendingTopics: map[string]heartbeatPendingTopic{}, |
| 358 | } |
| 359 | seed := HeartbeatTask{ |
| 360 | ID: "fresh", |
| 361 | Title: "Fresh", |
| 362 | Prompt: "ping", |
| 363 | NewConversationEachRun: true, |
| 364 | ApprovalMode: "auto", |
| 365 | } |
| 366 | if err := engine.saveTasks([]HeartbeatTask{seed}); err != nil { |
| 367 | t.Fatal(err) |
| 368 | } |
| 369 | engine.ReloadConfig() |
| 370 | ctrl := &heartbeatExecuteTaskCtrlStub{} |
| 371 | injected := make(chan struct{}) |
| 372 | |
| 373 | go func() { |
| 374 | ticker := time.NewTicker(time.Millisecond) |
| 375 | defer ticker.Stop() |
| 376 | for { |
| 377 | select { |
| 378 | case <-injected: |
| 379 | return |
| 380 | case <-ticker.C: |
| 381 | var cancel context.CancelFunc |
| 382 | var tabToInject *WorkspaceTab |
| 383 | app.mu.Lock() |
| 384 | for _, tab := range app.tabs { |
| 385 | if tab == nil { |
| 386 | continue |
| 387 | } |
| 388 | tab.removed = true |
| 389 | cancel = tab.buildCancel |
| 390 | tabToInject = tab |
| 391 | break |
| 392 | } |
| 393 | app.mu.Unlock() |
| 394 | if tabToInject == nil { |
| 395 | continue |
| 396 | } |
| 397 | if cancel != nil { |
| 398 | cancel() |
| 399 | } |
| 400 | app.mu.Lock() |
| 401 | if tabToInject.Ctrl == nil { |
| 402 | tabToInject.Ctrl = ctrl |
| 403 | tabToInject.Ready = true |
| 404 | tabToInject.StartupErr = "" |
| 405 | app.advanceSessionRuntimeEpochLocked(tabToInject) |
| 406 | app.mu.Unlock() |
| 407 | close(injected) |
| 408 | return |
| 409 | } |
| 410 | app.mu.Unlock() |
| 411 | } |
| 412 | } |
| 413 | }() |
| 414 | |
| 415 | got := engine.executeTaskWithLease(seed, nil) |
| 416 | |
| 417 | if got.TopicID == "" { |
| 418 | t.Fatal("fresh conversation task should return the newly created topic ID") |
| 419 | } |
| 420 | if got.LastRunAt == 0 { |
| 421 | t.Fatal("fresh conversation task should update LastRunAt after submit") |
| 422 | } |
| 423 | if len(ctrl.submitted) != 1 || ctrl.submitted[0] != "ping" { |
| 424 | t.Fatalf("submitted prompts = %v, want [ping]", ctrl.submitted) |
| 425 | } |
| 426 | if ctrl.approvalMode != "workspace-write" { |
| 427 | t.Fatalf("permission preset = %q, want workspace-write", ctrl.approvalMode) |
| 428 | } |
| 429 | pending := engine.pendingTopics["fresh"] |
| 430 | if pending.TopicID != got.TopicID || !pending.Submitted { |
| 431 | t.Fatalf("pending topic = %+v, want submitted %q", pending, got.TopicID) |
| 432 | } |
| 433 | } |
| 434 | |
| 435 | func TestHeartbeatExecuteTaskSkipsPendingPrompt(t *testing.T) { |
| 436 | isolateDesktopUserDirs(t) |
| 437 | app := NewApp() |
| 438 | app.ctx = context.Background() |
| 439 | app.readyHook = func() {} |
| 440 | app.runtimeEvents.emit = func(context.Context, string, ...any) {} |
| 441 | engine := &HeartbeatEngine{ |
| 442 | app: app, |
| 443 | pendingTopics: map[string]heartbeatPendingTopic{}, |
| 444 | } |
| 445 | seed := HeartbeatTask{ |
| 446 | ID: "fresh", |
| 447 | Title: "Fresh", |
| 448 | Prompt: "ping", |
| 449 | NewConversationEachRun: true, |
| 450 | ApprovalMode: "auto", |
| 451 | } |
| 452 | if err := engine.saveTasks([]HeartbeatTask{seed}); err != nil { |
| 453 | t.Fatal(err) |
| 454 | } |
| 455 | engine.ReloadConfig() |
| 456 | ctrl := &heartbeatExecuteTaskCtrlStub{status: control.RuntimeStatus{PendingPrompt: true}} |
| 457 | injected := make(chan struct{}) |
| 458 | |
| 459 | go func() { |
| 460 | ticker := time.NewTicker(time.Millisecond) |
| 461 | defer ticker.Stop() |
| 462 | for { |
| 463 | select { |
| 464 | case <-injected: |
| 465 | return |
| 466 | case <-ticker.C: |
| 467 | var cancel context.CancelFunc |
| 468 | var tabToInject *WorkspaceTab |
| 469 | app.mu.Lock() |
| 470 | for _, tab := range app.tabs { |
| 471 | if tab == nil { |
| 472 | continue |
| 473 | } |
| 474 | tab.removed = true |
| 475 | cancel = tab.buildCancel |
| 476 | tabToInject = tab |
| 477 | break |
| 478 | } |
| 479 | app.mu.Unlock() |
| 480 | if tabToInject == nil { |
| 481 | continue |
| 482 | } |
| 483 | if cancel != nil { |
| 484 | cancel() |
| 485 | } |
| 486 | app.mu.Lock() |
| 487 | if tabToInject.Ctrl == nil { |
| 488 | tabToInject.Ctrl = ctrl |
| 489 | tabToInject.Ready = true |
| 490 | tabToInject.StartupErr = "" |
| 491 | app.advanceSessionRuntimeEpochLocked(tabToInject) |
| 492 | app.mu.Unlock() |
| 493 | close(injected) |
| 494 | return |
| 495 | } |
| 496 | app.mu.Unlock() |
| 497 | } |
| 498 | } |
| 499 | }() |
| 500 | |
| 501 | got := engine.executeTaskWithLease(seed, nil) |
| 502 | |
| 503 | if got.LastRunAt != 0 { |
| 504 | t.Fatalf("pending prompt should not mark heartbeat run complete, LastRunAt=%d", got.LastRunAt) |
| 505 | } |
| 506 | if len(ctrl.submitted) != 0 { |
| 507 | t.Fatalf("submitted prompts = %v, want none while prompt is pending", ctrl.submitted) |
| 508 | } |
| 509 | if ctrl.approvalMode != "" { |
| 510 | t.Fatalf("approval mode = %q, want unchanged while prompt is pending", ctrl.approvalMode) |
| 511 | } |
| 512 | } |
| 513 | |
| 514 | func TestHeartbeatTaskDueAtHonorsIntervalTimeWindow(t *testing.T) { |
| 515 | loc := time.UTC |
| 516 | lastRun := time.Date(2026, 6, 18, 16, 0, 0, 0, loc) |
| 517 | task := HeartbeatTask{ |
| 518 | ID: "window", |
| 519 | Interval: "30m", |
| 520 | Enabled: true, |
| 521 | LastRunAt: lastRun.UnixMilli(), |
| 522 | TimeWindowStart: "09:00", |
| 523 | TimeWindowEnd: "17:00", |
| 524 | } |
| 525 | |
| 526 | if !heartbeatTaskDueAt(task, time.Date(2026, 6, 18, 16, 30, 0, 0, loc)) { |
| 527 | t.Fatal("interval task should run in the configured time window once due") |
| 528 | } |
| 529 | if heartbeatTaskDueAt(task, time.Date(2026, 6, 18, 17, 20, 0, 0, loc)) { |
| 530 | t.Fatal("interval task should wait while outside the configured time window") |
| 531 | } |
| 532 | if !heartbeatTaskDueAt(task, time.Date(2026, 6, 19, 9, 0, 0, 0, loc)) { |
| 533 | t.Fatal("interval task should run when the next time window opens") |
| 534 | } |
| 535 | |
| 536 | neverRun := HeartbeatTask{ |
| 537 | ID: "never-run-window", |
| 538 | Interval: "30m", |
| 539 | Enabled: true, |
| 540 | TimeWindowStart: "09:00", |
| 541 | TimeWindowEnd: "17:00", |
| 542 | } |
| 543 | if heartbeatTaskDueAt(neverRun, time.Date(2026, 6, 18, 20, 0, 0, 0, loc)) { |
| 544 | t.Fatal("never-run interval task should wait while outside the configured time window") |
| 545 | } |
| 546 | if !heartbeatTaskDueAt(neverRun, time.Date(2026, 6, 19, 9, 0, 0, 0, loc)) { |
| 547 | t.Fatal("never-run interval task should run when the configured time window opens") |
| 548 | } |
| 549 | } |
| 550 | |
| 551 | func TestHeartbeatMergeRunUpdatesPreservesConcurrentEditsAndDeletes(t *testing.T) { |
| 552 | isolateDesktopUserDirs(t) |
| 553 | engine := &HeartbeatEngine{ |
| 554 | tasks: []HeartbeatTask{ |
| 555 | {ID: "run", Title: "edited", Prompt: "new", Interval: "2h", Enabled: false, CreatedAt: 10}, |
| 556 | {ID: "keep", Title: "keep", Interval: "1h", Enabled: true}, |
| 557 | }, |
| 558 | } |
| 559 | |
| 560 | engine.mergeRunUpdatesLocked(map[string]HeartbeatTask{ |
| 561 | "run": { |
| 562 | ID: "run", |
| 563 | Title: "old", |
| 564 | Prompt: "old", |
| 565 | Interval: "1h", |
| 566 | Enabled: true, |
| 567 | TopicID: "topic-run", |
| 568 | LastRunAt: 200, |
| 569 | CreatedAt: 100, |
| 570 | }, |
| 571 | "deleted": { |
| 572 | ID: "deleted", |
| 573 | TopicID: "topic-deleted", |
| 574 | LastRunAt: 200, |
| 575 | }, |
| 576 | }) |
| 577 | |
| 578 | if len(engine.tasks) != 2 { |
| 579 | t.Fatalf("tasks len = %d, want 2", len(engine.tasks)) |
| 580 | } |
| 581 | got := engine.tasks[0] |
| 582 | if got.Title != "edited" || got.Prompt != "new" || got.Interval != "2h" || got.Enabled { |
| 583 | t.Fatalf("concurrent task edits were overwritten: %+v", got) |
| 584 | } |
| 585 | if got.TopicID != "topic-run" || got.LastRunAt != 200 || got.CreatedAt != 10 { |
| 586 | t.Fatalf("run fields were not patched correctly: %+v", got) |
| 587 | } |
| 588 | for _, task := range engine.tasks { |
| 589 | if task.ID == "deleted" { |
| 590 | t.Fatalf("deleted task was resurrected: %+v", engine.tasks) |
| 591 | } |
| 592 | } |
| 593 | } |
| 594 | |
| 595 | func TestHeartbeatMergeRunUpdatesNeverRegressesNewerRunState(t *testing.T) { |
| 596 | tasks := []HeartbeatTask{{ |
| 597 | ID: "run", |
| 598 | TopicID: "topic-new", |
| 599 | LastRunAt: 300, |
| 600 | }} |
| 601 | mergeHeartbeatRunUpdates(tasks, map[string]HeartbeatTask{ |
| 602 | "run": {ID: "run", TopicID: "topic-old", LastRunAt: 200}, |
| 603 | }) |
| 604 | if tasks[0].TopicID != "topic-new" || tasks[0].LastRunAt != 300 { |
| 605 | t.Fatalf("stale run state regressed the owner result: %+v", tasks[0]) |
| 606 | } |
| 607 | |
| 608 | mergeHeartbeatRunUpdates(tasks, map[string]HeartbeatTask{ |
| 609 | "run": {ID: "run", TopicID: "topic-latest", LastRunAt: 400}, |
| 610 | }) |
| 611 | if tasks[0].TopicID != "topic-latest" || tasks[0].LastRunAt != 400 { |
| 612 | t.Fatalf("newer run state was not adopted: %+v", tasks[0]) |
| 613 | } |
| 614 | } |
| 615 | |
| 616 | func TestHeartbeatReplaceTasksPrunesFreshConversationPendingTopics(t *testing.T) { |
| 617 | isolateDesktopUserDirs(t) |
| 618 | engine := &HeartbeatEngine{ |
| 619 | pendingTopics: map[string]heartbeatPendingTopic{ |
| 620 | "fresh": {TopicID: "topic-fresh", Submitted: true}, |
| 621 | "legacy": {TopicID: "topic-legacy", Submitted: true}, |
| 622 | "deleted": {TopicID: "topic-deleted", Submitted: true}, |
| 623 | }, |
| 624 | } |
| 625 | |
| 626 | err := engine.ReplaceTasks([]HeartbeatTask{ |
| 627 | {ID: "fresh", NewConversationEachRun: true}, |
| 628 | {ID: "legacy", NewConversationEachRun: false}, |
| 629 | }) |
| 630 | if err != nil { |
| 631 | t.Fatalf("ReplaceTasks: %v", err) |
| 632 | } |
| 633 | |
| 634 | if len(engine.pendingTopics) != 1 { |
| 635 | t.Fatalf("pendingTopics len = %d, want 1: %+v", len(engine.pendingTopics), engine.pendingTopics) |
| 636 | } |
| 637 | if got := engine.pendingTopics["fresh"]; got.TopicID != "topic-fresh" || !got.Submitted { |
| 638 | t.Fatalf("fresh pending topic = %+v, want submitted topic-fresh", got) |
| 639 | } |
| 640 | if _, ok := engine.pendingTopics["legacy"]; ok { |
| 641 | t.Fatalf("legacy task should not keep a fresh-conversation pending topic") |
| 642 | } |
| 643 | if _, ok := engine.pendingTopics["deleted"]; ok { |
| 644 | t.Fatalf("deleted task should not keep a pending topic") |
| 645 | } |
| 646 | } |
| 647 | |
| 648 | func TestHeartbeatInactiveOpenDoesNotChangeActiveTab(t *testing.T) { |
| 649 | isolateDesktopUserDirs(t) |
| 650 | projectRoot := t.TempDir() |
| 651 | app := &App{ |
| 652 | tabs: map[string]*WorkspaceTab{ |
| 653 | "heartbeat": { |
| 654 | ID: "heartbeat", |
| 655 | Scope: "project", |
| 656 | WorkspaceRoot: projectRoot, |
| 657 | TopicID: "topic-heartbeat", |
| 658 | TopicTitle: "Heartbeat", |
| 659 | Ready: true, |
| 660 | disabledMCP: map[string]ServerView{}, |
| 661 | }, |
| 662 | "active": { |
| 663 | ID: "active", |
| 664 | Scope: "project", |
| 665 | WorkspaceRoot: projectRoot, |
| 666 | TopicID: "topic-active", |
| 667 | TopicTitle: "Active", |
| 668 | Ready: true, |
| 669 | disabledMCP: map[string]ServerView{}, |
| 670 | }, |
| 671 | }, |
| 672 | tabOrder: []string{"heartbeat", "active"}, |
| 673 | activeTabID: "active", |
| 674 | } |
| 675 | |
| 676 | meta, err := app.openProjectTabInactive(projectRoot, "topic-heartbeat") |
| 677 | if err != nil { |
| 678 | t.Fatalf("openProjectTabInactive: %v", err) |
| 679 | } |
| 680 | if got := app.activeTabID; got != "active" { |
| 681 | t.Fatalf("active tab = %q, want active", got) |
| 682 | } |
| 683 | if meta.ID != "heartbeat" || meta.Active { |
| 684 | t.Fatalf("inactive open meta = %+v, want heartbeat and inactive", meta) |
| 685 | } |
| 686 | } |
| 687 | |
| 688 | func TestHeartbeatMergeRunUpdatesAdoptsExternalFileEdits(t *testing.T) { |
| 689 | isolateDesktopUserDirs(t) |
| 690 | engine := &HeartbeatEngine{ |
| 691 | tasks: []HeartbeatTask{ |
| 692 | {ID: "a", Title: "stale title", Prompt: "stale", Interval: "1h", Enabled: true}, |
| 693 | }, |
| 694 | } |
| 695 | // An external editor (the documented human/AI flow) rewrote the file after |
| 696 | // the engine's in-memory snapshot: task a was edited and task b was added. |
| 697 | external := []HeartbeatTask{ |
| 698 | {ID: "a", Title: "edited externally", Prompt: "new prompt", Interval: "2h", Enabled: true}, |
| 699 | {ID: "b", Title: "added externally", Prompt: "hello", Interval: "1h", Enabled: false}, |
| 700 | } |
| 701 | if err := engine.saveTasks(external); err != nil { |
| 702 | t.Fatalf("seed external file: %v", err) |
| 703 | } |
| 704 | |
| 705 | engine.mergeRunUpdatesLocked(map[string]HeartbeatTask{ |
| 706 | "a": {ID: "a", TopicID: "topic-a", LastRunAt: 4242}, |
| 707 | }) |
| 708 | |
| 709 | if len(engine.tasks) != 2 { |
| 710 | t.Fatalf("tasks len = %d, want 2 (external addition adopted): %+v", len(engine.tasks), engine.tasks) |
| 711 | } |
| 712 | got := engine.tasks[0] |
| 713 | if got.Title != "edited externally" || got.Prompt != "new prompt" || got.Interval != "2h" { |
| 714 | t.Fatalf("external edit was rolled back by the run-state save: %+v", got) |
| 715 | } |
| 716 | if got.TopicID != "topic-a" || got.LastRunAt != 4242 { |
| 717 | t.Fatalf("run state was not merged onto the disk copy: %+v", got) |
| 718 | } |
| 719 | // The full-list save must have preserved the externally added task on disk. |
| 720 | onDisk := engine.loadTasks() |
| 721 | if len(onDisk) != 2 || onDisk[1].ID != "b" || onDisk[1].Title != "added externally" { |
| 722 | t.Fatalf("externally added task was lost on save: %+v", onDisk) |
| 723 | } |
| 724 | } |
| 725 | |
| 726 | func TestHeartbeatTickAdoptsExternalFileEdits(t *testing.T) { |
| 727 | isolateDesktopUserDirs(t) |
| 728 | engine := newHeartbeatEngine(nil) |
| 729 | if err := engine.saveTasks([]HeartbeatTask{{ID: "a", Title: "A", Interval: "1h", Enabled: false}}); err != nil { |
| 730 | t.Fatalf("seed file: %v", err) |
| 731 | } |
| 732 | engine.mu.Lock() |
| 733 | engine.tasks = engine.loadTasks() |
| 734 | engine.mu.Unlock() |
| 735 | |
| 736 | // External edit lands after the engine last touched the file. Force the |
| 737 | // mtime forward so coarse filesystem timestamps cannot make this flaky. |
| 738 | if err := engine.saveTasks([]HeartbeatTask{ |
| 739 | {ID: "a", Title: "A", Interval: "1h", Enabled: false}, |
| 740 | {ID: "b", Title: "added externally", Interval: "1h", Enabled: false}, |
| 741 | }); err != nil { |
| 742 | t.Fatalf("external edit: %v", err) |
| 743 | } |
| 744 | future := time.Now().Add(2 * time.Second) |
| 745 | if err := os.Chtimes(engine.configPath(), future, future); err != nil { |
| 746 | t.Fatalf("chtimes: %v", err) |
| 747 | } |
| 748 | |
| 749 | engine.tick() // disabled tasks only: adoption runs, nothing executes |
| 750 | |
| 751 | tasks := engine.ListTasks() |
| 752 | if len(tasks) != 2 || tasks[1].ID != "b" { |
| 753 | t.Fatalf("tick did not adopt the external edit: %+v", tasks) |
| 754 | } |
| 755 | } |
| 756 | |
| 757 | func TestHeartbeatExternalDeletionDoesNotResurrectTasks(t *testing.T) { |
| 758 | isolateDesktopUserDirs(t) |
| 759 | engine := newHeartbeatEngine(nil) |
| 760 | if err := engine.saveTasks([]HeartbeatTask{{ID: "deleted", Title: "old", Interval: "1h", Enabled: true}}); err != nil { |
| 761 | t.Fatal(err) |
| 762 | } |
| 763 | snapshot, err := engine.readConfigSnapshot() |
| 764 | if err != nil { |
| 765 | t.Fatal(err) |
| 766 | } |
| 767 | engine.mu.Lock() |
| 768 | engine.recordConfigSnapshotLocked(snapshot) |
| 769 | engine.tasks = append([]HeartbeatTask(nil), snapshot.cfg.Tasks...) |
| 770 | if err := os.Remove(engine.configPath()); err != nil { |
| 771 | engine.mu.Unlock() |
| 772 | t.Fatal(err) |
| 773 | } |
| 774 | engine.adoptExternalEditsLocked() |
| 775 | if len(engine.tasks) != 0 || !engine.cfgDeleted { |
| 776 | engine.mu.Unlock() |
| 777 | t.Fatalf("deleted config left stale tasks: tasks=%+v deleted=%v", engine.tasks, engine.cfgDeleted) |
| 778 | } |
| 779 | engine.mergeRunUpdatesLocked(map[string]HeartbeatTask{"deleted": {ID: "deleted", LastRunAt: 123}}) |
| 780 | engine.mu.Unlock() |
| 781 | if _, err := os.Stat(engine.configPath()); !os.IsNotExist(err) { |
| 782 | t.Fatalf("deleted heartbeat config was recreated, stat err=%v", err) |
| 783 | } |
| 784 | } |
| 785 |