relay-flow 0.3.8-alpha → 0.3.10-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +27 -19
  2. package/cmd/relay-flow/commands_test.go +440 -21
  3. package/cmd/relay-flow/main.go +203 -37
  4. package/cmd/relay-flow/observability_test.go +11 -0
  5. package/cmd/relay-flow/onboarding.go +1 -1
  6. package/cmd/relay-flow/onboarding_test.go +1 -1
  7. package/cmd/relay-flow/scenario_test.go +26 -10
  8. package/cmd/relay-flow/serve.go +27 -1
  9. package/internal/execution/goworkflows/activities.go +11 -0
  10. package/internal/execution/goworkflows/cancellation_test.go +50 -0
  11. package/internal/execution/goworkflows/engine.go +278 -9
  12. package/internal/execution/goworkflows/fakes_test.go +30 -1
  13. package/internal/execution/goworkflows/interpreter.go +18 -1
  14. package/internal/execution/goworkflows/ownership_test.go +259 -0
  15. package/internal/execution/goworkflows/projection.go +8 -0
  16. package/internal/execution/goworkflows/recovery_test.go +563 -1
  17. package/internal/execution/projection/detail_test.go +38 -0
  18. package/internal/execution/projection/projection.go +80 -15
  19. package/internal/execution/temporal/interpreter.go +2 -1
  20. package/internal/execution/temporal/recovery.go +68 -36
  21. package/internal/harness/harness.go +1 -0
  22. package/internal/harness/opencode/opencode.go +5 -0
  23. package/internal/harness/opencode/opencode_test.go +17 -1
  24. package/internal/harness/opencode/repo_setup.go +1 -1
  25. package/internal/harness/pi/pi.go +6 -0
  26. package/internal/harness/pi/task_env_test.go +16 -0
  27. package/internal/recover/recover.go +30 -1
  28. package/internal/repo/binding_test.go +63 -0
  29. package/internal/repo/repo.go +47 -6
  30. package/internal/router/router.go +39 -5
  31. package/internal/router/router_test.go +67 -1
  32. package/internal/run/manager.go +90 -3
  33. package/internal/run/run_manager_test.go +74 -0
  34. package/internal/server/api_test.go +13 -0
  35. package/internal/server/client.go +7 -0
  36. package/internal/server/fixture_test.go +5 -0
  37. package/internal/server/server.go +32 -3
  38. package/internal/task/beads/beads.go +201 -3
  39. package/internal/task/beads/beads_test.go +172 -0
  40. package/internal/task/jira/effects_test.go +86 -0
  41. package/internal/task/jira/filters_test.go +103 -0
  42. package/internal/task/jira/helpers_test.go +11 -2
  43. package/internal/task/jira/jira.go +228 -4
  44. package/internal/task/jira/normalize.go +30 -15
  45. package/internal/task/jira/rest/client.go +17 -2
  46. package/internal/task/jira/transition_defaults_test.go +3 -1
  47. package/internal/task/task.go +66 -0
  48. package/internal/workflow/report.go +3 -0
  49. package/internal/workflow/report_test.go +16 -0
  50. package/internal/workflow/workflow.go +24 -0
  51. package/internal/workflow/workflow_test.go +58 -1
  52. package/package.json +1 -1
@@ -46,6 +46,14 @@ func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.St
46
46
  return p.shared().UpdateState(ctx, id, state, lastErr, finished)
47
47
  }
48
48
 
49
+ func (p *RunProjection) beginCancellation(ctx context.Context, id run.ID, reason string) (run.Run, error) {
50
+ return p.shared().BeginCancellation(ctx, id, reason)
51
+ }
52
+
53
+ func (p *RunProjection) updateStateIf(ctx context.Context, id run.ID, expected, state run.State, lastErr string, finished *time.Time) (bool, error) {
54
+ return p.shared().UpdateStateIf(ctx, id, expected, state, lastErr, finished)
55
+ }
56
+
49
57
  func (p *RunProjection) updateRetry(ctx context.Context, id run.ID, status *run.RetryStatus) error {
50
58
  return p.shared().UpdateRetry(ctx, id, status)
51
59
  }
@@ -9,6 +9,8 @@ import (
9
9
  "testing"
10
10
  "time"
11
11
 
12
+ "github.com/cschleiden/go-workflows/backend/history"
13
+ "github.com/rajpopat27/relay-flow/internal/config"
12
14
  "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
13
15
  "github.com/rajpopat27/relay-flow/internal/identity"
14
16
  recoverpkg "github.com/rajpopat27/relay-flow/internal/recover"
@@ -200,14 +202,19 @@ func TestCancelRun(t *testing.T) {
200
202
  // Exactly one parent cancellation comment with the stable marker.
201
203
  wantMarker := string(rid) + ":cancellation"
202
204
  var cancelComments int
205
+ var cancellationBody string
203
206
  for _, c := range sys.commentBodies("PAY-101") {
204
207
  if c.Marker == wantMarker {
205
208
  cancelComments++
209
+ cancellationBody = c.Body
206
210
  }
207
211
  }
208
212
  if cancelComments != 1 {
209
213
  t.Fatalf("cancellation comments = %d, want 1 with marker %q", cancelComments, wantMarker)
210
214
  }
215
+ if !strings.Contains(cancellationBody, "Run canceled: no longer needed") {
216
+ t.Fatalf("cancellation comment = %q, want persisted operator reason", cancellationBody)
217
+ }
211
218
 
212
219
  // Mailbox statuses/history unchanged: the in-flight coding mailbox was
213
220
  // not completed by cancellation.
@@ -220,6 +227,482 @@ func TestCancelRun(t *testing.T) {
220
227
  }
221
228
  }
222
229
 
230
+ func TestCancelRunFinalizesWhenWorkflowInstanceIsMissing(t *testing.T) {
231
+ log := newEventLog()
232
+ sys := newFakeTaskSystem(log)
233
+ fr := newFakeRunner(log)
234
+ deps := goworkflows.Dependencies{
235
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
236
+ Runtime: &run.RuntimePolicy{},
237
+ }
238
+ dbPath := filepath.Join(t.TempDir(), "state.db")
239
+ first, err := goworkflows.New(dbPath, deps)
240
+ if err != nil {
241
+ t.Fatal(err)
242
+ }
243
+ if err := first.Start(context.Background()); err != nil {
244
+ t.Fatal(err)
245
+ }
246
+ rid, err := startRun(first, linearWorkflow(false))
247
+ if err != nil {
248
+ t.Fatal(err)
249
+ }
250
+ waitFor(t, 10*time.Second, func() bool {
251
+ r, _ := first.GetRun(context.Background(), rid)
252
+ return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
253
+ })
254
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
255
+ if err := first.Shutdown(shutdownCtx); err != nil {
256
+ t.Fatal(err)
257
+ }
258
+ cancel()
259
+
260
+ // Simulate the cancellation crash boundary: the relay projection has a
261
+ // run, but the active go-workflows instance is gone.
262
+ db, err := sql.Open("sqlite", dbPath)
263
+ if err != nil {
264
+ t.Fatal(err)
265
+ }
266
+ if _, err := db.Exec(`DELETE FROM instances WHERE id = ?`, string(rid)); err != nil {
267
+ db.Close()
268
+ t.Fatal(err)
269
+ }
270
+ if err := db.Close(); err != nil {
271
+ t.Fatal(err)
272
+ }
273
+
274
+ second, err := goworkflows.New(dbPath, deps)
275
+ if err != nil {
276
+ t.Fatal(err)
277
+ }
278
+ defer func() { _ = second.Shutdown(context.Background()) }()
279
+ sys.failComments = true
280
+ if err := second.CancelRun(context.Background(), rid, "operator canceled"); err == nil {
281
+ t.Fatal("CancelRun succeeded despite a cancellation-comment failure")
282
+ }
283
+ failed, err := second.GetRun(context.Background(), rid)
284
+ if err != nil {
285
+ t.Fatal(err)
286
+ }
287
+ if failed.State != run.StateCanceling || failed.LastError != "operator canceled" {
288
+ t.Fatalf("failed cancellation projection = %+v, want canceling with original reason", failed)
289
+ }
290
+ sys.failComments = false
291
+ if err := second.CancelRun(context.Background(), rid, "replacement reason"); err != nil {
292
+ t.Fatalf("CancelRun with missing workflow instance: %v", err)
293
+ }
294
+ got, err := second.GetRun(context.Background(), rid)
295
+ if err != nil {
296
+ t.Fatal(err)
297
+ }
298
+ if got.State != run.StateCanceled {
299
+ t.Fatalf("state = %q, want canceled", got.State)
300
+ }
301
+ active, err := second.HasActiveWorkflow(context.Background(), "basicFlow")
302
+ if err != nil {
303
+ t.Fatal(err)
304
+ }
305
+ if active {
306
+ t.Fatal("canceled run is still counted as an active workflow")
307
+ }
308
+ if fr.liveTerminals() != 0 {
309
+ t.Fatalf("missing-instance cancellation left %d live terminals", fr.liveTerminals())
310
+ }
311
+ comments := sys.commentBodies("PAY-101")
312
+ if len(comments) != 1 || comments[0].Marker != string(rid)+":cancellation" {
313
+ t.Fatalf("cancellation comments = %#v, want one stable cancellation marker", comments)
314
+ }
315
+ if err := second.CancelRun(context.Background(), rid, "repeated cancellation"); err != nil {
316
+ t.Fatalf("repeated cancellation: %v", err)
317
+ }
318
+ if got, err := second.GetRun(context.Background(), rid); err != nil || got.State != run.StateCanceled {
319
+ t.Fatalf("repeated cancellation changed state: run=%+v err=%v", got, err)
320
+ }
321
+ }
322
+
323
+ func TestStartReconcilesCancelingRunWithoutWorkflowInstance(t *testing.T) {
324
+ log := newEventLog()
325
+ sys := newFakeTaskSystem(log)
326
+ fr := newFakeRunner(log)
327
+ deps := goworkflows.Dependencies{
328
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
329
+ Runtime: &run.RuntimePolicy{},
330
+ }
331
+ dbPath := filepath.Join(t.TempDir(), "state.db")
332
+ first, err := goworkflows.New(dbPath, deps)
333
+ if err != nil {
334
+ t.Fatal(err)
335
+ }
336
+ if err := first.Start(context.Background()); err != nil {
337
+ t.Fatal(err)
338
+ }
339
+ rid, err := startRun(first, linearWorkflow(false))
340
+ if err != nil {
341
+ t.Fatal(err)
342
+ }
343
+ waitFor(t, 10*time.Second, func() bool {
344
+ r, _ := first.GetRun(context.Background(), rid)
345
+ return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
346
+ })
347
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
348
+ if err := first.Shutdown(shutdownCtx); err != nil {
349
+ t.Fatal(err)
350
+ }
351
+ cancel()
352
+
353
+ db, err := sql.Open("sqlite", dbPath)
354
+ if err != nil {
355
+ t.Fatal(err)
356
+ }
357
+ if _, err := db.Exec(`UPDATE relay_runs SET state = ?, last_error = ? WHERE id = ?`,
358
+ string(run.StateCanceling), "operator canceled", string(rid)); err != nil {
359
+ db.Close()
360
+ t.Fatal(err)
361
+ }
362
+ if _, err := db.Exec(`DELETE FROM instances WHERE id = ?`, string(rid)); err != nil {
363
+ db.Close()
364
+ t.Fatal(err)
365
+ }
366
+ if err := db.Close(); err != nil {
367
+ t.Fatal(err)
368
+ }
369
+
370
+ second, err := goworkflows.New(dbPath, deps)
371
+ if err != nil {
372
+ t.Fatal(err)
373
+ }
374
+ if err := second.Start(context.Background()); err != nil {
375
+ t.Fatal(err)
376
+ }
377
+ defer func() { _ = second.Shutdown(context.Background()) }()
378
+ got, err := second.GetRun(context.Background(), rid)
379
+ if err != nil {
380
+ t.Fatal(err)
381
+ }
382
+ if got.State != run.StateCanceled {
383
+ t.Fatalf("startup reconciliation state = %q, want canceled", got.State)
384
+ }
385
+ active, err := second.HasActiveWorkflow(context.Background(), "basicFlow")
386
+ if err != nil {
387
+ t.Fatal(err)
388
+ }
389
+ if active {
390
+ t.Fatal("startup-reconciled run is still counted as active")
391
+ }
392
+ if len(sys.commentBodies("PAY-101")) != 1 {
393
+ t.Fatalf("startup reconciliation comments = %d, want one", len(sys.commentBodies("PAY-101")))
394
+ }
395
+ }
396
+
397
+ func TestStartRetriesCancellationForExistingWorkflowInstance(t *testing.T) {
398
+ log := newEventLog()
399
+ sys := newFakeTaskSystem(log)
400
+ fr := newFakeRunner(log)
401
+ deps := goworkflows.Dependencies{
402
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
403
+ Runtime: &run.RuntimePolicy{},
404
+ }
405
+ dbPath := filepath.Join(t.TempDir(), "state.db")
406
+ first, err := goworkflows.New(dbPath, deps)
407
+ if err != nil {
408
+ t.Fatal(err)
409
+ }
410
+ if err := first.Start(context.Background()); err != nil {
411
+ t.Fatal(err)
412
+ }
413
+ rid, err := startRun(first, linearWorkflow(false))
414
+ if err != nil {
415
+ t.Fatal(err)
416
+ }
417
+ waitFor(t, 10*time.Second, func() bool {
418
+ r, _ := first.GetRun(context.Background(), rid)
419
+ return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
420
+ })
421
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
422
+ if err := first.Shutdown(shutdownCtx); err != nil {
423
+ t.Fatal(err)
424
+ }
425
+ cancel()
426
+
427
+ // Leave the engine instance active but persist the cancellation request as
428
+ // if the process died after the projection write and before the cancel RPC.
429
+ db, err := sql.Open("sqlite", dbPath)
430
+ if err != nil {
431
+ t.Fatal(err)
432
+ }
433
+ if _, err := db.Exec(`UPDATE relay_runs SET state = ?, last_error = ? WHERE id = ?`,
434
+ string(run.StateCanceling), "startup cancellation", string(rid)); err != nil {
435
+ db.Close()
436
+ t.Fatal(err)
437
+ }
438
+ if err := db.Close(); err != nil {
439
+ t.Fatal(err)
440
+ }
441
+
442
+ second, err := goworkflows.New(dbPath, deps)
443
+ if err != nil {
444
+ t.Fatal(err)
445
+ }
446
+ if err := second.Start(context.Background()); err != nil {
447
+ t.Fatal(err)
448
+ }
449
+ defer func() { _ = second.Shutdown(context.Background()) }()
450
+ waitFor(t, 30*time.Second, func() bool {
451
+ r, _ := second.GetRun(context.Background(), rid)
452
+ return r.State == run.StateCanceled
453
+ })
454
+ if len(sys.commentBodies("PAY-101")) != 1 {
455
+ t.Fatalf("startup cancellation comments = %d, want one", len(sys.commentBodies("PAY-101")))
456
+ }
457
+ }
458
+
459
+ func TestStartDistinguishesFinishedWorkflowFromMissingWorkflow(t *testing.T) {
460
+ log := newEventLog()
461
+ sys := newFakeTaskSystem(log)
462
+ fr := newFakeRunner(log)
463
+ deps := goworkflows.Dependencies{
464
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
465
+ Runtime: &run.RuntimePolicy{},
466
+ }
467
+ dbPath := filepath.Join(t.TempDir(), "state.db")
468
+ first, err := goworkflows.New(dbPath, deps)
469
+ if err != nil {
470
+ t.Fatal(err)
471
+ }
472
+ if err := first.Start(context.Background()); err != nil {
473
+ t.Fatal(err)
474
+ }
475
+ rid, err := startRun(first, linearWorkflow(false))
476
+ if err != nil {
477
+ t.Fatal(err)
478
+ }
479
+ waitFor(t, 10*time.Second, func() bool {
480
+ r, _ := first.GetRun(context.Background(), rid)
481
+ return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
482
+ })
483
+ if _, err := first.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end"))); err != nil {
484
+ t.Fatal(err)
485
+ }
486
+ waitFor(t, 30*time.Second, func() bool {
487
+ r, _ := first.GetRun(context.Background(), rid)
488
+ return r.State == run.StateCompleted
489
+ })
490
+ waitFor(t, 10*time.Second, func() bool {
491
+ db, err := sql.Open("sqlite", dbPath)
492
+ if err != nil {
493
+ return false
494
+ }
495
+ defer db.Close()
496
+ var state int
497
+ if err := db.QueryRow(`SELECT state FROM instances WHERE id = ?`, string(rid)).Scan(&state); err != nil {
498
+ return false
499
+ }
500
+ return state != 0
501
+ })
502
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
503
+ if err := first.Shutdown(shutdownCtx); err != nil {
504
+ t.Fatal(err)
505
+ }
506
+ cancel()
507
+
508
+ // Corrupt only the relay projection into canceling. The finished engine
509
+ // row/history must win; this must not create a cancellation comment.
510
+ db, err := sql.Open("sqlite", dbPath)
511
+ if err != nil {
512
+ t.Fatal(err)
513
+ }
514
+ if _, err := db.Exec(`UPDATE relay_runs SET state = ?, last_error = ?, finished_at = NULL WHERE id = ?`,
515
+ string(run.StateCanceling), "late cancellation", string(rid)); err != nil {
516
+ db.Close()
517
+ t.Fatal(err)
518
+ }
519
+ // Make the engine fixture unambiguously finished while retaining its
520
+ // history, so startup must inspect the terminal event rather than treating
521
+ // the row as a missing active execution.
522
+ if _, err := db.Exec(`UPDATE instances SET state = 2, completed_at = CURRENT_TIMESTAMP WHERE id = ?`, string(rid)); err != nil {
523
+ db.Close()
524
+ t.Fatal(err)
525
+ }
526
+ if err := db.Close(); err != nil {
527
+ t.Fatal(err)
528
+ }
529
+
530
+ second, err := goworkflows.New(dbPath, deps)
531
+ if err != nil {
532
+ t.Fatal(err)
533
+ }
534
+ if err := second.Start(context.Background()); err != nil {
535
+ t.Fatal(err)
536
+ }
537
+ defer func() { _ = second.Shutdown(context.Background()) }()
538
+ got, err := second.GetRun(context.Background(), rid)
539
+ if err != nil {
540
+ t.Fatal(err)
541
+ }
542
+ if got.State != run.StateCompleted {
543
+ t.Fatalf("finished workflow was reconciled to %q, want completed", got.State)
544
+ }
545
+ if comments := sys.commentBodies("PAY-101"); len(comments) != 0 {
546
+ t.Fatalf("finished workflow received cancellation comments: %#v", comments)
547
+ }
548
+ }
549
+
550
+ func TestStartReconcilesCanceledWorkflowHistoryAsCanceled(t *testing.T) {
551
+ log := newEventLog()
552
+ sys := newFakeTaskSystem(log)
553
+ fr := newFakeRunner(log)
554
+ deps := goworkflows.Dependencies{
555
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
556
+ Runtime: &run.RuntimePolicy{},
557
+ }
558
+ dbPath := filepath.Join(t.TempDir(), "state.db")
559
+ first, err := goworkflows.New(dbPath, deps)
560
+ if err != nil {
561
+ t.Fatal(err)
562
+ }
563
+ if err := first.Start(context.Background()); err != nil {
564
+ t.Fatal(err)
565
+ }
566
+ rid, err := startRun(first, linearWorkflow(false))
567
+ if err != nil {
568
+ t.Fatal(err)
569
+ }
570
+ waitFor(t, 10*time.Second, func() bool {
571
+ r, _ := first.GetRun(context.Background(), rid)
572
+ return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
573
+ })
574
+ if err := first.CancelRun(context.Background(), rid, "history cancellation"); err != nil {
575
+ t.Fatal(err)
576
+ }
577
+ waitFor(t, 30*time.Second, func() bool {
578
+ r, _ := first.GetRun(context.Background(), rid)
579
+ return r.State == run.StateCanceled
580
+ })
581
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
582
+ if err := first.Shutdown(shutdownCtx); err != nil {
583
+ t.Fatal(err)
584
+ }
585
+ cancel()
586
+
587
+ db, err := sql.Open("sqlite", dbPath)
588
+ if err != nil {
589
+ t.Fatal(err)
590
+ }
591
+ if _, err := db.Exec(`UPDATE relay_runs SET state = ?, last_error = ?, finished_at = NULL WHERE id = ?`,
592
+ string(run.StateCanceling), "history cancellation", string(rid)); err != nil {
593
+ db.Close()
594
+ t.Fatal(err)
595
+ }
596
+ if _, err := db.Exec(`UPDATE instances SET state = 2, completed_at = CURRENT_TIMESTAMP WHERE id = ?`, string(rid)); err != nil {
597
+ db.Close()
598
+ t.Fatal(err)
599
+ }
600
+ if err := db.Close(); err != nil {
601
+ t.Fatal(err)
602
+ }
603
+
604
+ second, err := goworkflows.New(dbPath, deps)
605
+ if err != nil {
606
+ t.Fatal(err)
607
+ }
608
+ if err := second.Start(context.Background()); err != nil {
609
+ t.Fatal(err)
610
+ }
611
+ defer func() { _ = second.Shutdown(context.Background()) }()
612
+ got, err := second.GetRun(context.Background(), rid)
613
+ if err != nil {
614
+ t.Fatal(err)
615
+ }
616
+ if got.State != run.StateCanceled {
617
+ t.Fatalf("canceled workflow history was reconciled to %q, want canceled", got.State)
618
+ }
619
+ if comments := sys.commentBodies("PAY-101"); len(comments) != 1 {
620
+ t.Fatalf("cancellation comments = %d, want one idempotent comment", len(comments))
621
+ }
622
+ }
623
+
624
+ func TestRepeatedCancellationDoesNotAppendCancellationEvents(t *testing.T) {
625
+ log := newEventLog()
626
+ sys := newFakeTaskSystem(log)
627
+ fr := newFakeRunner(log)
628
+ deps := goworkflows.Dependencies{
629
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
630
+ Runtime: &run.RuntimePolicy{},
631
+ }
632
+ dbPath := filepath.Join(t.TempDir(), "state.db")
633
+ first, err := goworkflows.New(dbPath, deps)
634
+ if err != nil {
635
+ t.Fatal(err)
636
+ }
637
+ if err := first.Start(context.Background()); err != nil {
638
+ t.Fatal(err)
639
+ }
640
+ rid, err := startRun(first, linearWorkflow(false))
641
+ if err != nil {
642
+ t.Fatal(err)
643
+ }
644
+ waitFor(t, 10*time.Second, func() bool {
645
+ r, _ := first.GetRun(context.Background(), rid)
646
+ return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
647
+ })
648
+ sys.failComments = true
649
+ if err := first.CancelRun(context.Background(), rid, "first reason"); err != nil {
650
+ t.Fatal(err)
651
+ }
652
+ waitFor(t, 15*time.Second, func() bool {
653
+ r, _ := first.GetRun(context.Background(), rid)
654
+ return r.State == run.StateCanceling
655
+ })
656
+ if err := first.CancelRun(context.Background(), rid, "second reason"); err != nil {
657
+ t.Fatal(err)
658
+ }
659
+ if got := countWorkflowEvents(t, dbPath, rid, history.EventType_WorkflowExecutionCanceled); got != 1 {
660
+ t.Fatalf("cancellation events after repeated cancel = %d, want 1", got)
661
+ }
662
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
663
+ if err := first.Shutdown(shutdownCtx); err != nil {
664
+ t.Fatal(err)
665
+ }
666
+ cancel()
667
+
668
+ second, err := goworkflows.New(dbPath, deps)
669
+ if err != nil {
670
+ t.Fatal(err)
671
+ }
672
+ if err := second.Start(context.Background()); err != nil {
673
+ t.Fatal(err)
674
+ }
675
+ defer func() { _ = second.Shutdown(context.Background()) }()
676
+ // Let the already-persisted cancellation event drive cleanup after the
677
+ // restart; startup must not append another event.
678
+ sys.failComments = false
679
+ waitFor(t, 30*time.Second, func() bool {
680
+ r, _ := second.GetRun(context.Background(), rid)
681
+ return r.State == run.StateCanceled
682
+ })
683
+ if got := countWorkflowEvents(t, dbPath, rid, history.EventType_WorkflowExecutionCanceled); got != 1 {
684
+ t.Fatalf("cancellation events after restart = %d, want 1", got)
685
+ }
686
+ }
687
+
688
+ func countWorkflowEvents(t *testing.T, dbPath string, id run.ID, eventType history.EventType) int {
689
+ t.Helper()
690
+ db, err := sql.Open("sqlite", dbPath)
691
+ if err != nil {
692
+ t.Fatal(err)
693
+ }
694
+ defer db.Close()
695
+ var count int
696
+ if err := db.QueryRow(`
697
+ SELECT
698
+ (SELECT COUNT(*) FROM history WHERE instance_id = ? AND event_type = ?)
699
+ + (SELECT COUNT(*) FROM pending_events WHERE instance_id = ? AND event_type = ?)`,
700
+ string(id), int(eventType), string(id), int(eventType)).Scan(&count); err != nil {
701
+ t.Fatal(err)
702
+ }
703
+ return count
704
+ }
705
+
223
706
  func TestExplicitRestartCreatesFreshAttemptFromStart(t *testing.T) {
224
707
  log := newEventLog()
225
708
  sys := newFakeTaskSystem(log)
@@ -876,7 +1359,7 @@ func TestDatabaseFileIsOwnerOnly(t *testing.T) {
876
1359
  // task.System/runner.Runner plus the real engine and the real RunManager.
877
1360
  // Rewritten in 6.3 from a test-local copy so the production wiring is
878
1361
  // actually covered.
879
- func recoverTickets(ctx context.Context, engine *goworkflows.Engine, sys *fakeTaskSystem, fr *fakeRunner, wf workflow.Workflow) error {
1362
+ func recoverTickets(ctx context.Context, engine *goworkflows.Engine, sys task.System, fr *fakeRunner, wf workflow.Workflow) error {
880
1363
  reg := repo.NewRegistry()
881
1364
  reg.Replace(&repo.Repo{
882
1365
  Name: "payments",
@@ -894,6 +1377,85 @@ func recoverTickets(ctx context.Context, engine *goworkflows.Engine, sys *fakeTa
894
1377
  return recoverpkg.FromTaskSystem(ctx, reg, fr, rm, specsFor)
895
1378
  }
896
1379
 
1380
+ type mismatchedRecoveryTaskSystem struct{ *fakeTaskSystem }
1381
+
1382
+ type legacyRecoveryTaskSystem struct{ *fakeTaskSystem }
1383
+
1384
+ func (s *legacyRecoveryTaskSystem) CompileOwnershipFilter(config.RawValues) (func(task.Ticket) bool, error) {
1385
+ return func(task.Ticket) bool { return false }, nil
1386
+ }
1387
+
1388
+ func (s *mismatchedRecoveryTaskSystem) CompileOwnershipFilter(config.RawValues) (func(task.Ticket) bool, error) {
1389
+ // Let the router resolve the claim; the current-provider read below is the
1390
+ // mismatch gate being exercised by this recovery regression.
1391
+ return func(task.Ticket) bool { return true }, nil
1392
+ }
1393
+
1394
+ func (s *mismatchedRecoveryTaskSystem) ValidateOwnership(context.Context, task.TicketRef, string, config.RawValues) error {
1395
+ return &task.OwnershipMismatchError{Ticket: "PAY-101", Workflow: "basicFlow"}
1396
+ }
1397
+
1398
+ func TestServeRecoverSkipsOwnerMismatchBeforeMutations(t *testing.T) {
1399
+ log := newEventLog()
1400
+ base := newFakeTaskSystem(log)
1401
+ sys := &mismatchedRecoveryTaskSystem{fakeTaskSystem: base}
1402
+ sys.parentsToRecover = []task.Ticket{{ID: "1", Key: "PAY-101", WorkflowClaims: []string{"wf:basicFlow"}}}
1403
+ fr := newFakeRunner(log)
1404
+ engine := newEngine(t, goworkflows.Dependencies{
1405
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
1406
+ })
1407
+ wf := linearWorkflow(false)
1408
+ reg := repo.NewRegistry()
1409
+ reg.Replace(&repo.Repo{Name: "payments", Path: "/srv/payments", TaskSystem: sys})
1410
+ if err := reg.BindWorkflows([]*workflow.Workflow{&wf}); err != nil {
1411
+ t.Fatal(err)
1412
+ }
1413
+ rm := &run.RunManager{Executor: engine, Runs: engine}
1414
+ specsFor := func(system task.System, work run.Work, w *workflow.Workflow) ([]task.MailboxSpec, error) {
1415
+ return goworkflows.RenderMailboxSpecs(system, work, w)
1416
+ }
1417
+ if err := recoverpkg.FromTaskSystem(context.Background(), reg, fr, rm, specsFor); err != nil {
1418
+ t.Fatalf("recovery failed on owner mismatch: %v", err)
1419
+ }
1420
+ if len(fr.closedRun) != 0 {
1421
+ t.Fatalf("CloseTerminals calls = %v, want none", fr.closedRun)
1422
+ }
1423
+ if base.hasCommentCount() != 0 {
1424
+ t.Fatalf("HasComment calls = %d, want none before owner rejection", base.hasCommentCount())
1425
+ }
1426
+ if len(base.resets) != 0 {
1427
+ t.Fatalf("ResetForRecovery calls = %v, want none", base.resets)
1428
+ }
1429
+ for _, event := range log.all() {
1430
+ if strings.HasPrefix(event, "ensureMailboxes:") || strings.HasPrefix(event, "createMailbox:") {
1431
+ t.Fatalf("mailbox mutation after owner mismatch: %v", log.all())
1432
+ }
1433
+ }
1434
+ if runs, err := engine.ListRuns(context.Background(), run.Filter{Repo: "payments", Workflow: "basicFlow", Ticket: "PAY-101"}); err != nil || len(runs) != 0 {
1435
+ t.Fatalf("durable runs after owner mismatch = %v, %v; want none", runs, err)
1436
+ }
1437
+ }
1438
+
1439
+ func TestServeRecoverSkipsLegacyClaimWithoutProvenance(t *testing.T) {
1440
+ log := newEventLog()
1441
+ base := newFakeTaskSystem(log)
1442
+ sys := &legacyRecoveryTaskSystem{fakeTaskSystem: base}
1443
+ sys.parentsToRecover = []task.Ticket{{ID: "1", Key: "PAY-101", WorkflowClaims: []string{"wf:basicFlow"}}}
1444
+ fr := newFakeRunner(log)
1445
+ engine := newEngine(t, goworkflows.Dependencies{
1446
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
1447
+ })
1448
+ if err := recoverTickets(context.Background(), engine, sys, fr, linearWorkflow(false)); err != nil {
1449
+ t.Fatalf("legacy-claim recovery failed: %v", err)
1450
+ }
1451
+ if len(fr.closedRun) != 0 || base.hasCommentCount() != 0 || len(base.resets) != 0 {
1452
+ t.Fatalf("legacy claim caused recovery side effects: closed=%v comments=%d resets=%v", fr.closedRun, base.hasCommentCount(), base.resets)
1453
+ }
1454
+ if runs, err := engine.ListRuns(context.Background(), run.Filter{Repo: "payments", Workflow: "basicFlow", Ticket: "PAY-101"}); err != nil || len(runs) != 0 {
1455
+ t.Fatalf("legacy claim durable runs = %v, %v; want none", runs, err)
1456
+ }
1457
+ }
1458
+
897
1459
  func TestServeRecoverRebuildsFreshRuns(t *testing.T) {
898
1460
  log := newEventLog()
899
1461
  sys := newFakeTaskSystem(log)
@@ -206,6 +206,44 @@ func TestCompletedRunFinalizesActiveStepWhenFinalUpsertIsMissing(t *testing.T) {
206
206
  }
207
207
  }
208
208
 
209
+ func TestCancellationFencePreservesReasonAndRejectsLateCompletion(t *testing.T) {
210
+ ctx := context.Background()
211
+ p, _ := openProjection(t)
212
+ start := projectionStart("cancel-fence", "PAY-CANCEL-FENCE")
213
+ if err := p.InsertStart(ctx, start, time.Now().UTC()); err != nil {
214
+ t.Fatal(err)
215
+ }
216
+ first, err := p.BeginCancellation(ctx, start.ID, "first reason")
217
+ if err != nil {
218
+ t.Fatal(err)
219
+ }
220
+ if first.State != run.StateCanceling || first.LastError != "first reason" {
221
+ t.Fatalf("first cancellation = %+v", first)
222
+ }
223
+ second, err := p.BeginCancellation(ctx, start.ID, "replacement reason")
224
+ if err != nil {
225
+ t.Fatal(err)
226
+ }
227
+ if second.State != run.StateCanceling || second.LastError != "first reason" {
228
+ t.Fatalf("repeated cancellation overwrote reason: %+v", second)
229
+ }
230
+ if err := p.UpdateState(ctx, start.ID, run.StateCompleted, "", nil); err != nil {
231
+ t.Fatal(err)
232
+ }
233
+ stillCanceling, err := p.Get(ctx, start.ID)
234
+ if err != nil {
235
+ t.Fatal(err)
236
+ }
237
+ if stillCanceling.State != run.StateCanceling || stillCanceling.LastError != "first reason" {
238
+ t.Fatalf("late completion overwrote cancellation: %+v", stillCanceling)
239
+ }
240
+ finished := time.Now().UTC()
241
+ updated, err := p.UpdateStateIf(ctx, start.ID, run.StateCanceling, run.StateCanceled, "", &finished)
242
+ if err != nil || !updated {
243
+ t.Fatalf("canceling finalization updated=%v err=%v", updated, err)
244
+ }
245
+ }
246
+
209
247
  func TestCanceledRunFinalizesCurrentStepTiming(t *testing.T) {
210
248
  ctx := context.Background()
211
249
  p, _ := openProjection(t)