返回 DeepSeek-Reasonix
heartbeat_test.go
根目录 / desktop / heartbeat_test.go
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
785 lines GO