relay-flow 0.3.7-alpha → 0.3.9-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 (34) hide show
  1. package/README.md +5 -5
  2. package/cmd/relay-flow/observability_test.go +28 -0
  3. package/cmd/relay-flow/render.go +20 -4
  4. package/cmd/relay-flow/scenario_test.go +15 -13
  5. package/cmd/relay-flow/serve.go +116 -103
  6. package/internal/execution/goworkflows/activities.go +17 -0
  7. package/internal/execution/goworkflows/cancellation_test.go +50 -0
  8. package/internal/execution/goworkflows/engine.go +278 -9
  9. package/internal/execution/goworkflows/fakes_test.go +10 -1
  10. package/internal/execution/goworkflows/interpreter.go +17 -1
  11. package/internal/execution/goworkflows/projection.go +8 -0
  12. package/internal/execution/goworkflows/recovery_test.go +482 -0
  13. package/internal/execution/projection/detail_test.go +38 -0
  14. package/internal/execution/projection/projection.go +80 -15
  15. package/internal/execution/temporal/activities.go +6 -0
  16. package/internal/execution/temporal/recovery.go +4 -0
  17. package/internal/harness/opencode/opencode_test.go +1 -1
  18. package/internal/harness/opencode/repo_setup.go +1 -1
  19. package/internal/recover/recover.go +4 -0
  20. package/internal/repo/binding_test.go +92 -0
  21. package/internal/repo/poller.go +3 -0
  22. package/internal/repo/repo.go +82 -6
  23. package/internal/run/manager.go +41 -0
  24. package/internal/run/run_manager_test.go +56 -0
  25. package/internal/server/observability.go +1 -1
  26. package/internal/task/beads/beads.go +42 -4
  27. package/internal/task/beads/beads_test.go +15 -0
  28. package/internal/task/factory.go +41 -2
  29. package/internal/task/jira/jira.go +52 -28
  30. package/internal/workflow/service.go +53 -0
  31. package/internal/workflow/store.go +413 -19
  32. package/internal/workflow/store_test.go +235 -0
  33. package/internal/workflow/workflow.go +71 -0
  34. package/package.json +1 -1
@@ -9,6 +9,7 @@ import (
9
9
  "testing"
10
10
  "time"
11
11
 
12
+ "github.com/cschleiden/go-workflows/backend/history"
12
13
  "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
13
14
  "github.com/rajpopat27/relay-flow/internal/identity"
14
15
  recoverpkg "github.com/rajpopat27/relay-flow/internal/recover"
@@ -200,14 +201,19 @@ func TestCancelRun(t *testing.T) {
200
201
  // Exactly one parent cancellation comment with the stable marker.
201
202
  wantMarker := string(rid) + ":cancellation"
202
203
  var cancelComments int
204
+ var cancellationBody string
203
205
  for _, c := range sys.commentBodies("PAY-101") {
204
206
  if c.Marker == wantMarker {
205
207
  cancelComments++
208
+ cancellationBody = c.Body
206
209
  }
207
210
  }
208
211
  if cancelComments != 1 {
209
212
  t.Fatalf("cancellation comments = %d, want 1 with marker %q", cancelComments, wantMarker)
210
213
  }
214
+ if !strings.Contains(cancellationBody, "Run canceled: no longer needed") {
215
+ t.Fatalf("cancellation comment = %q, want persisted operator reason", cancellationBody)
216
+ }
211
217
 
212
218
  // Mailbox statuses/history unchanged: the in-flight coding mailbox was
213
219
  // not completed by cancellation.
@@ -220,6 +226,482 @@ func TestCancelRun(t *testing.T) {
220
226
  }
221
227
  }
222
228
 
229
+ func TestCancelRunFinalizesWhenWorkflowInstanceIsMissing(t *testing.T) {
230
+ log := newEventLog()
231
+ sys := newFakeTaskSystem(log)
232
+ fr := newFakeRunner(log)
233
+ deps := goworkflows.Dependencies{
234
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
235
+ Runtime: &run.RuntimePolicy{},
236
+ }
237
+ dbPath := filepath.Join(t.TempDir(), "state.db")
238
+ first, err := goworkflows.New(dbPath, deps)
239
+ if err != nil {
240
+ t.Fatal(err)
241
+ }
242
+ if err := first.Start(context.Background()); err != nil {
243
+ t.Fatal(err)
244
+ }
245
+ rid, err := startRun(first, linearWorkflow(false))
246
+ if err != nil {
247
+ t.Fatal(err)
248
+ }
249
+ waitFor(t, 10*time.Second, func() bool {
250
+ r, _ := first.GetRun(context.Background(), rid)
251
+ return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
252
+ })
253
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
254
+ if err := first.Shutdown(shutdownCtx); err != nil {
255
+ t.Fatal(err)
256
+ }
257
+ cancel()
258
+
259
+ // Simulate the cancellation crash boundary: the relay projection has a
260
+ // run, but the active go-workflows instance is gone.
261
+ db, err := sql.Open("sqlite", dbPath)
262
+ if err != nil {
263
+ t.Fatal(err)
264
+ }
265
+ if _, err := db.Exec(`DELETE FROM instances WHERE id = ?`, string(rid)); err != nil {
266
+ db.Close()
267
+ t.Fatal(err)
268
+ }
269
+ if err := db.Close(); err != nil {
270
+ t.Fatal(err)
271
+ }
272
+
273
+ second, err := goworkflows.New(dbPath, deps)
274
+ if err != nil {
275
+ t.Fatal(err)
276
+ }
277
+ defer func() { _ = second.Shutdown(context.Background()) }()
278
+ sys.failComments = true
279
+ if err := second.CancelRun(context.Background(), rid, "operator canceled"); err == nil {
280
+ t.Fatal("CancelRun succeeded despite a cancellation-comment failure")
281
+ }
282
+ failed, err := second.GetRun(context.Background(), rid)
283
+ if err != nil {
284
+ t.Fatal(err)
285
+ }
286
+ if failed.State != run.StateCanceling || failed.LastError != "operator canceled" {
287
+ t.Fatalf("failed cancellation projection = %+v, want canceling with original reason", failed)
288
+ }
289
+ sys.failComments = false
290
+ if err := second.CancelRun(context.Background(), rid, "replacement reason"); err != nil {
291
+ t.Fatalf("CancelRun with missing workflow instance: %v", err)
292
+ }
293
+ got, err := second.GetRun(context.Background(), rid)
294
+ if err != nil {
295
+ t.Fatal(err)
296
+ }
297
+ if got.State != run.StateCanceled {
298
+ t.Fatalf("state = %q, want canceled", got.State)
299
+ }
300
+ active, err := second.HasActiveWorkflow(context.Background(), "basicFlow")
301
+ if err != nil {
302
+ t.Fatal(err)
303
+ }
304
+ if active {
305
+ t.Fatal("canceled run is still counted as an active workflow")
306
+ }
307
+ if fr.liveTerminals() != 0 {
308
+ t.Fatalf("missing-instance cancellation left %d live terminals", fr.liveTerminals())
309
+ }
310
+ comments := sys.commentBodies("PAY-101")
311
+ if len(comments) != 1 || comments[0].Marker != string(rid)+":cancellation" {
312
+ t.Fatalf("cancellation comments = %#v, want one stable cancellation marker", comments)
313
+ }
314
+ if err := second.CancelRun(context.Background(), rid, "repeated cancellation"); err != nil {
315
+ t.Fatalf("repeated cancellation: %v", err)
316
+ }
317
+ if got, err := second.GetRun(context.Background(), rid); err != nil || got.State != run.StateCanceled {
318
+ t.Fatalf("repeated cancellation changed state: run=%+v err=%v", got, err)
319
+ }
320
+ }
321
+
322
+ func TestStartReconcilesCancelingRunWithoutWorkflowInstance(t *testing.T) {
323
+ log := newEventLog()
324
+ sys := newFakeTaskSystem(log)
325
+ fr := newFakeRunner(log)
326
+ deps := goworkflows.Dependencies{
327
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
328
+ Runtime: &run.RuntimePolicy{},
329
+ }
330
+ dbPath := filepath.Join(t.TempDir(), "state.db")
331
+ first, err := goworkflows.New(dbPath, deps)
332
+ if err != nil {
333
+ t.Fatal(err)
334
+ }
335
+ if err := first.Start(context.Background()); err != nil {
336
+ t.Fatal(err)
337
+ }
338
+ rid, err := startRun(first, linearWorkflow(false))
339
+ if err != nil {
340
+ t.Fatal(err)
341
+ }
342
+ waitFor(t, 10*time.Second, func() bool {
343
+ r, _ := first.GetRun(context.Background(), rid)
344
+ return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
345
+ })
346
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
347
+ if err := first.Shutdown(shutdownCtx); err != nil {
348
+ t.Fatal(err)
349
+ }
350
+ cancel()
351
+
352
+ db, err := sql.Open("sqlite", dbPath)
353
+ if err != nil {
354
+ t.Fatal(err)
355
+ }
356
+ if _, err := db.Exec(`UPDATE relay_runs SET state = ?, last_error = ? WHERE id = ?`,
357
+ string(run.StateCanceling), "operator canceled", string(rid)); err != nil {
358
+ db.Close()
359
+ t.Fatal(err)
360
+ }
361
+ if _, err := db.Exec(`DELETE FROM instances WHERE id = ?`, string(rid)); err != nil {
362
+ db.Close()
363
+ t.Fatal(err)
364
+ }
365
+ if err := db.Close(); err != nil {
366
+ t.Fatal(err)
367
+ }
368
+
369
+ second, err := goworkflows.New(dbPath, deps)
370
+ if err != nil {
371
+ t.Fatal(err)
372
+ }
373
+ if err := second.Start(context.Background()); err != nil {
374
+ t.Fatal(err)
375
+ }
376
+ defer func() { _ = second.Shutdown(context.Background()) }()
377
+ got, err := second.GetRun(context.Background(), rid)
378
+ if err != nil {
379
+ t.Fatal(err)
380
+ }
381
+ if got.State != run.StateCanceled {
382
+ t.Fatalf("startup reconciliation state = %q, want canceled", got.State)
383
+ }
384
+ active, err := second.HasActiveWorkflow(context.Background(), "basicFlow")
385
+ if err != nil {
386
+ t.Fatal(err)
387
+ }
388
+ if active {
389
+ t.Fatal("startup-reconciled run is still counted as active")
390
+ }
391
+ if len(sys.commentBodies("PAY-101")) != 1 {
392
+ t.Fatalf("startup reconciliation comments = %d, want one", len(sys.commentBodies("PAY-101")))
393
+ }
394
+ }
395
+
396
+ func TestStartRetriesCancellationForExistingWorkflowInstance(t *testing.T) {
397
+ log := newEventLog()
398
+ sys := newFakeTaskSystem(log)
399
+ fr := newFakeRunner(log)
400
+ deps := goworkflows.Dependencies{
401
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
402
+ Runtime: &run.RuntimePolicy{},
403
+ }
404
+ dbPath := filepath.Join(t.TempDir(), "state.db")
405
+ first, err := goworkflows.New(dbPath, deps)
406
+ if err != nil {
407
+ t.Fatal(err)
408
+ }
409
+ if err := first.Start(context.Background()); err != nil {
410
+ t.Fatal(err)
411
+ }
412
+ rid, err := startRun(first, linearWorkflow(false))
413
+ if err != nil {
414
+ t.Fatal(err)
415
+ }
416
+ waitFor(t, 10*time.Second, func() bool {
417
+ r, _ := first.GetRun(context.Background(), rid)
418
+ return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
419
+ })
420
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
421
+ if err := first.Shutdown(shutdownCtx); err != nil {
422
+ t.Fatal(err)
423
+ }
424
+ cancel()
425
+
426
+ // Leave the engine instance active but persist the cancellation request as
427
+ // if the process died after the projection write and before the cancel RPC.
428
+ db, err := sql.Open("sqlite", dbPath)
429
+ if err != nil {
430
+ t.Fatal(err)
431
+ }
432
+ if _, err := db.Exec(`UPDATE relay_runs SET state = ?, last_error = ? WHERE id = ?`,
433
+ string(run.StateCanceling), "startup cancellation", string(rid)); err != nil {
434
+ db.Close()
435
+ t.Fatal(err)
436
+ }
437
+ if err := db.Close(); err != nil {
438
+ t.Fatal(err)
439
+ }
440
+
441
+ second, err := goworkflows.New(dbPath, deps)
442
+ if err != nil {
443
+ t.Fatal(err)
444
+ }
445
+ if err := second.Start(context.Background()); err != nil {
446
+ t.Fatal(err)
447
+ }
448
+ defer func() { _ = second.Shutdown(context.Background()) }()
449
+ waitFor(t, 30*time.Second, func() bool {
450
+ r, _ := second.GetRun(context.Background(), rid)
451
+ return r.State == run.StateCanceled
452
+ })
453
+ if len(sys.commentBodies("PAY-101")) != 1 {
454
+ t.Fatalf("startup cancellation comments = %d, want one", len(sys.commentBodies("PAY-101")))
455
+ }
456
+ }
457
+
458
+ func TestStartDistinguishesFinishedWorkflowFromMissingWorkflow(t *testing.T) {
459
+ log := newEventLog()
460
+ sys := newFakeTaskSystem(log)
461
+ fr := newFakeRunner(log)
462
+ deps := goworkflows.Dependencies{
463
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
464
+ Runtime: &run.RuntimePolicy{},
465
+ }
466
+ dbPath := filepath.Join(t.TempDir(), "state.db")
467
+ first, err := goworkflows.New(dbPath, deps)
468
+ if err != nil {
469
+ t.Fatal(err)
470
+ }
471
+ if err := first.Start(context.Background()); err != nil {
472
+ t.Fatal(err)
473
+ }
474
+ rid, err := startRun(first, linearWorkflow(false))
475
+ if err != nil {
476
+ t.Fatal(err)
477
+ }
478
+ waitFor(t, 10*time.Second, func() bool {
479
+ r, _ := first.GetRun(context.Background(), rid)
480
+ return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
481
+ })
482
+ if _, err := first.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end"))); err != nil {
483
+ t.Fatal(err)
484
+ }
485
+ waitFor(t, 30*time.Second, func() bool {
486
+ r, _ := first.GetRun(context.Background(), rid)
487
+ return r.State == run.StateCompleted
488
+ })
489
+ waitFor(t, 10*time.Second, func() bool {
490
+ db, err := sql.Open("sqlite", dbPath)
491
+ if err != nil {
492
+ return false
493
+ }
494
+ defer db.Close()
495
+ var state int
496
+ if err := db.QueryRow(`SELECT state FROM instances WHERE id = ?`, string(rid)).Scan(&state); err != nil {
497
+ return false
498
+ }
499
+ return state != 0
500
+ })
501
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
502
+ if err := first.Shutdown(shutdownCtx); err != nil {
503
+ t.Fatal(err)
504
+ }
505
+ cancel()
506
+
507
+ // Corrupt only the relay projection into canceling. The finished engine
508
+ // row/history must win; this must not create a cancellation comment.
509
+ db, err := sql.Open("sqlite", dbPath)
510
+ if err != nil {
511
+ t.Fatal(err)
512
+ }
513
+ if _, err := db.Exec(`UPDATE relay_runs SET state = ?, last_error = ?, finished_at = NULL WHERE id = ?`,
514
+ string(run.StateCanceling), "late cancellation", string(rid)); err != nil {
515
+ db.Close()
516
+ t.Fatal(err)
517
+ }
518
+ // Make the engine fixture unambiguously finished while retaining its
519
+ // history, so startup must inspect the terminal event rather than treating
520
+ // the row as a missing active execution.
521
+ if _, err := db.Exec(`UPDATE instances SET state = 2, completed_at = CURRENT_TIMESTAMP WHERE id = ?`, string(rid)); err != nil {
522
+ db.Close()
523
+ t.Fatal(err)
524
+ }
525
+ if err := db.Close(); err != nil {
526
+ t.Fatal(err)
527
+ }
528
+
529
+ second, err := goworkflows.New(dbPath, deps)
530
+ if err != nil {
531
+ t.Fatal(err)
532
+ }
533
+ if err := second.Start(context.Background()); err != nil {
534
+ t.Fatal(err)
535
+ }
536
+ defer func() { _ = second.Shutdown(context.Background()) }()
537
+ got, err := second.GetRun(context.Background(), rid)
538
+ if err != nil {
539
+ t.Fatal(err)
540
+ }
541
+ if got.State != run.StateCompleted {
542
+ t.Fatalf("finished workflow was reconciled to %q, want completed", got.State)
543
+ }
544
+ if comments := sys.commentBodies("PAY-101"); len(comments) != 0 {
545
+ t.Fatalf("finished workflow received cancellation comments: %#v", comments)
546
+ }
547
+ }
548
+
549
+ func TestStartReconcilesCanceledWorkflowHistoryAsCanceled(t *testing.T) {
550
+ log := newEventLog()
551
+ sys := newFakeTaskSystem(log)
552
+ fr := newFakeRunner(log)
553
+ deps := goworkflows.Dependencies{
554
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
555
+ Runtime: &run.RuntimePolicy{},
556
+ }
557
+ dbPath := filepath.Join(t.TempDir(), "state.db")
558
+ first, err := goworkflows.New(dbPath, deps)
559
+ if err != nil {
560
+ t.Fatal(err)
561
+ }
562
+ if err := first.Start(context.Background()); err != nil {
563
+ t.Fatal(err)
564
+ }
565
+ rid, err := startRun(first, linearWorkflow(false))
566
+ if err != nil {
567
+ t.Fatal(err)
568
+ }
569
+ waitFor(t, 10*time.Second, func() bool {
570
+ r, _ := first.GetRun(context.Background(), rid)
571
+ return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
572
+ })
573
+ if err := first.CancelRun(context.Background(), rid, "history cancellation"); err != nil {
574
+ t.Fatal(err)
575
+ }
576
+ waitFor(t, 30*time.Second, func() bool {
577
+ r, _ := first.GetRun(context.Background(), rid)
578
+ return r.State == run.StateCanceled
579
+ })
580
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
581
+ if err := first.Shutdown(shutdownCtx); err != nil {
582
+ t.Fatal(err)
583
+ }
584
+ cancel()
585
+
586
+ db, err := sql.Open("sqlite", dbPath)
587
+ if err != nil {
588
+ t.Fatal(err)
589
+ }
590
+ if _, err := db.Exec(`UPDATE relay_runs SET state = ?, last_error = ?, finished_at = NULL WHERE id = ?`,
591
+ string(run.StateCanceling), "history cancellation", string(rid)); err != nil {
592
+ db.Close()
593
+ t.Fatal(err)
594
+ }
595
+ if _, err := db.Exec(`UPDATE instances SET state = 2, completed_at = CURRENT_TIMESTAMP WHERE id = ?`, string(rid)); err != nil {
596
+ db.Close()
597
+ t.Fatal(err)
598
+ }
599
+ if err := db.Close(); err != nil {
600
+ t.Fatal(err)
601
+ }
602
+
603
+ second, err := goworkflows.New(dbPath, deps)
604
+ if err != nil {
605
+ t.Fatal(err)
606
+ }
607
+ if err := second.Start(context.Background()); err != nil {
608
+ t.Fatal(err)
609
+ }
610
+ defer func() { _ = second.Shutdown(context.Background()) }()
611
+ got, err := second.GetRun(context.Background(), rid)
612
+ if err != nil {
613
+ t.Fatal(err)
614
+ }
615
+ if got.State != run.StateCanceled {
616
+ t.Fatalf("canceled workflow history was reconciled to %q, want canceled", got.State)
617
+ }
618
+ if comments := sys.commentBodies("PAY-101"); len(comments) != 1 {
619
+ t.Fatalf("cancellation comments = %d, want one idempotent comment", len(comments))
620
+ }
621
+ }
622
+
623
+ func TestRepeatedCancellationDoesNotAppendCancellationEvents(t *testing.T) {
624
+ log := newEventLog()
625
+ sys := newFakeTaskSystem(log)
626
+ fr := newFakeRunner(log)
627
+ deps := goworkflows.Dependencies{
628
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
629
+ Runtime: &run.RuntimePolicy{},
630
+ }
631
+ dbPath := filepath.Join(t.TempDir(), "state.db")
632
+ first, err := goworkflows.New(dbPath, deps)
633
+ if err != nil {
634
+ t.Fatal(err)
635
+ }
636
+ if err := first.Start(context.Background()); err != nil {
637
+ t.Fatal(err)
638
+ }
639
+ rid, err := startRun(first, linearWorkflow(false))
640
+ if err != nil {
641
+ t.Fatal(err)
642
+ }
643
+ waitFor(t, 10*time.Second, func() bool {
644
+ r, _ := first.GetRun(context.Background(), rid)
645
+ return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
646
+ })
647
+ sys.failComments = true
648
+ if err := first.CancelRun(context.Background(), rid, "first reason"); err != nil {
649
+ t.Fatal(err)
650
+ }
651
+ waitFor(t, 15*time.Second, func() bool {
652
+ r, _ := first.GetRun(context.Background(), rid)
653
+ return r.State == run.StateCanceling
654
+ })
655
+ if err := first.CancelRun(context.Background(), rid, "second reason"); err != nil {
656
+ t.Fatal(err)
657
+ }
658
+ if got := countWorkflowEvents(t, dbPath, rid, history.EventType_WorkflowExecutionCanceled); got != 1 {
659
+ t.Fatalf("cancellation events after repeated cancel = %d, want 1", got)
660
+ }
661
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
662
+ if err := first.Shutdown(shutdownCtx); err != nil {
663
+ t.Fatal(err)
664
+ }
665
+ cancel()
666
+
667
+ second, err := goworkflows.New(dbPath, deps)
668
+ if err != nil {
669
+ t.Fatal(err)
670
+ }
671
+ if err := second.Start(context.Background()); err != nil {
672
+ t.Fatal(err)
673
+ }
674
+ defer func() { _ = second.Shutdown(context.Background()) }()
675
+ // Let the already-persisted cancellation event drive cleanup after the
676
+ // restart; startup must not append another event.
677
+ sys.failComments = false
678
+ waitFor(t, 30*time.Second, func() bool {
679
+ r, _ := second.GetRun(context.Background(), rid)
680
+ return r.State == run.StateCanceled
681
+ })
682
+ if got := countWorkflowEvents(t, dbPath, rid, history.EventType_WorkflowExecutionCanceled); got != 1 {
683
+ t.Fatalf("cancellation events after restart = %d, want 1", got)
684
+ }
685
+ }
686
+
687
+ func countWorkflowEvents(t *testing.T, dbPath string, id run.ID, eventType history.EventType) int {
688
+ t.Helper()
689
+ db, err := sql.Open("sqlite", dbPath)
690
+ if err != nil {
691
+ t.Fatal(err)
692
+ }
693
+ defer db.Close()
694
+ var count int
695
+ if err := db.QueryRow(`
696
+ SELECT
697
+ (SELECT COUNT(*) FROM history WHERE instance_id = ? AND event_type = ?)
698
+ + (SELECT COUNT(*) FROM pending_events WHERE instance_id = ? AND event_type = ?)`,
699
+ string(id), int(eventType), string(id), int(eventType)).Scan(&count); err != nil {
700
+ t.Fatal(err)
701
+ }
702
+ return count
703
+ }
704
+
223
705
  func TestExplicitRestartCreatesFreshAttemptFromStart(t *testing.T) {
224
706
  log := newEventLog()
225
707
  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)