relay-flow 0.3.9-alpha → 0.3.11-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 (57) hide show
  1. package/README.md +30 -34
  2. package/cmd/relay-flow/commands_test.go +449 -23
  3. package/cmd/relay-flow/main.go +207 -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/examples/config-reference.yaml +23 -19
  10. package/examples/workflow-reference.yaml +3 -0
  11. package/internal/execution/goworkflows/activities.go +13 -22
  12. package/internal/execution/goworkflows/engine_test.go +17 -3
  13. package/internal/execution/goworkflows/fakes_test.go +20 -0
  14. package/internal/execution/goworkflows/interpreter.go +25 -11
  15. package/internal/execution/goworkflows/mailbox_test.go +28 -1
  16. package/internal/execution/goworkflows/ownership_test.go +259 -0
  17. package/internal/execution/goworkflows/recovery_test.go +81 -1
  18. package/internal/execution/temporal/activities.go +13 -22
  19. package/internal/execution/temporal/compact_mailbox_test.go +56 -0
  20. package/internal/execution/temporal/interpreter.go +16 -3
  21. package/internal/execution/temporal/recovery.go +68 -36
  22. package/internal/harness/harness.go +23 -10
  23. package/internal/harness/opencode/opencode.go +22 -4
  24. package/internal/harness/opencode/opencode_test.go +41 -1
  25. package/internal/harness/opencode/repo_setup.go +1 -1
  26. package/internal/harness/opencode/task_env_test.go +2 -2
  27. package/internal/harness/pi/pi.go +21 -2
  28. package/internal/harness/pi/pi_test.go +7 -4
  29. package/internal/harness/pi/prompt_test.go +13 -5
  30. package/internal/harness/pi/task_env_test.go +18 -2
  31. package/internal/recover/recover.go +30 -1
  32. package/internal/repo/binding_test.go +63 -0
  33. package/internal/repo/repo.go +47 -6
  34. package/internal/router/router.go +39 -5
  35. package/internal/router/router_test.go +67 -1
  36. package/internal/run/manager.go +90 -3
  37. package/internal/run/run_manager_test.go +74 -0
  38. package/internal/server/api_test.go +13 -0
  39. package/internal/server/client.go +7 -0
  40. package/internal/server/fixture_test.go +5 -0
  41. package/internal/server/server.go +32 -3
  42. package/internal/task/beads/beads.go +201 -3
  43. package/internal/task/beads/beads_test.go +172 -0
  44. package/internal/task/jira/effects_test.go +86 -0
  45. package/internal/task/jira/filters_test.go +103 -0
  46. package/internal/task/jira/helpers_test.go +11 -2
  47. package/internal/task/jira/jira.go +244 -15
  48. package/internal/task/jira/normalize.go +30 -15
  49. package/internal/task/jira/rest/client.go +17 -2
  50. package/internal/task/jira/templates_test.go +38 -2
  51. package/internal/task/jira/transition_defaults_test.go +3 -1
  52. package/internal/task/task.go +67 -0
  53. package/internal/workflow/report.go +10 -0
  54. package/internal/workflow/report_test.go +16 -0
  55. package/internal/workflow/workflow.go +24 -0
  56. package/internal/workflow/workflow_test.go +58 -1
  57. package/package.json +1 -1
@@ -61,13 +61,25 @@ var (
61
61
  buildDate = "unknown"
62
62
  )
63
63
 
64
- func main() { os.Exit(run(os.Args[1:], os.Stdin)) }
64
+ var runningAsMain = false
65
+
66
+ const serveChildEnvironment = "RELAY_FLOW_SERVE_CHILD"
67
+
68
+ func main() {
69
+ runningAsMain = true
70
+ os.Exit(run(os.Args[1:], os.Stdin))
71
+ }
65
72
 
66
73
  // home returns the relay-flow root, honoring RELAY_FLOW_HOME (tests and
67
- // non-standard installs) and falling back to ~/.relay-flow.
74
+ // non-standard installs) and falling back to ~/.relay-flow. The configured
75
+ // root is made absolute so detached children never resolve it through a
76
+ // caller directory that may disappear after launch.
68
77
  func home() (paths.Paths, error) {
69
78
  if env := os.Getenv("RELAY_FLOW_HOME"); env != "" {
70
- root := env
79
+ root, err := filepath.Abs(env)
80
+ if err != nil {
81
+ return paths.Paths{}, fmt.Errorf("resolve RELAY_FLOW_HOME: %w", err)
82
+ }
71
83
  return paths.Paths{
72
84
  Root: root,
73
85
  Config: filepath.Join(root, "config.yaml"),
@@ -150,6 +162,7 @@ func usage(w io.Writer) {
150
162
 
151
163
  Usage:
152
164
  relay-flow [command] [flags]
165
+ rf [command] [flags] (equivalent executable name)
153
166
 
154
167
  Setup and server:
155
168
  relay-flow init [--force] [--task-plugin <name> --runner-plugin <name> --harness-plugin <name>]
@@ -157,8 +170,13 @@ Setup and server:
157
170
  [--temporal-address <host:port>] [--temporal-namespace <name>]
158
171
  (interactive first run also authenticates and optionally registers repos)
159
172
  relay-flow task auth [task-plugin options]
160
- relay-flow serve [--recover] [--debug] [--background]
173
+ relay-flow serve [--recover] [--debug] [--foreground | --background]
161
174
  relay-flow stop
175
+
176
+ Plain serve starts detached, waits for Unix-socket readiness, and then returns.
177
+ Use serve --foreground for a blocking process-supervisor/development mode.
178
+ Detached startup diagnostics are written to ~/.relay-flow/server.log (or
179
+ $RELAY_FLOW_HOME/server.log when a custom home is configured).
162
180
  relay-flow report
163
181
  relay-flow version | --version | -v
164
182
 
@@ -179,6 +197,7 @@ Run:
179
197
  relay-flow run get --ticket <key> [--json]
180
198
  relay-flow run restart --ticket <key>
181
199
  relay-flow run cancel --ticket <key>
200
+ relay-flow run backfill-owner --repo <name> --ticket <key> --workflow <name>
182
201
 
183
202
  Use relay-flow <command> --help for command-specific details.
184
203
 
@@ -247,9 +266,12 @@ func printScopedHelp(args []string, w io.Writer) int {
247
266
  fmt.Fprintln(w, " --force Update plugin selections while preserving existing durable state.")
248
267
  fmt.Fprintln(w, "Example: relay-flow init --task-plugin jira --runner-plugin orca --harness-plugin opencode")
249
268
  case "serve":
250
- fmt.Fprintln(w, "Usage: relay-flow serve [--recover] [--debug] [--background]")
251
- fmt.Fprintln(w, "\nStart the relay-flow server.")
252
- fmt.Fprintln(w, "Example: relay-flow serve --background")
269
+ fmt.Fprintln(w, "Usage: relay-flow serve [--recover] [--debug] [--foreground | --background]")
270
+ fmt.Fprintln(w, "\nStart relay-flow detached and wait for the Unix socket to become ready.")
271
+ fmt.Fprintln(w, " --foreground Run the server in the current process until stop or signal.")
272
+ fmt.Fprintln(w, " --background Compatibility spelling for the default detached mode.")
273
+ fmt.Fprintln(w, "The detached server writes diagnostics to ~/.relay-flow/server.log (or RELAY_FLOW_HOME/server.log).")
274
+ fmt.Fprintln(w, "Examples: rf serve; relay-flow serve --foreground")
253
275
  case "stop":
254
276
  fmt.Fprintln(w, "Usage: relay-flow stop")
255
277
  fmt.Fprintln(w, "\nStop the running relay-flow server.")
@@ -384,6 +406,9 @@ func printScopedHelp(args []string, w io.Writer) int {
384
406
  fmt.Fprintln(w, " cancel Permanently cancel the current execution attempt.")
385
407
  fmt.Fprintln(w, " Usage: relay-flow run cancel --ticket <key> [--reason <text>]")
386
408
  fmt.Fprintln(w, " Example: relay-flow run cancel --ticket PAY-101 --reason "+`"operator request"`)
409
+ fmt.Fprintln(w, " backfill-owner Explicitly add provenance to a legacy workflow claim.")
410
+ fmt.Fprintln(w, " Usage: relay-flow run backfill-owner --repo <name> --ticket <key> --workflow <name>")
411
+ fmt.Fprintln(w, " Example: relay-flow run backfill-owner --repo payments --ticket PAY-101 --workflow basicFlow")
387
412
  fmt.Fprintln(w, "Use relay-flow run <subcommand> --help for flag details.")
388
413
  case "run list":
389
414
  fmt.Fprintln(w, "Usage: relay-flow run list [--repo <name>] [--workflow <name>] [--ticket <key>] [--active] [--json] [--no-color]")
@@ -418,6 +443,15 @@ func printScopedHelp(args []string, w io.Writer) int {
418
443
  fmt.Fprintln(w, " --ticket <key> Task ticket key (required).")
419
444
  fmt.Fprintln(w, " --reason <text> Optional cancellation reason recorded with the request.")
420
445
  fmt.Fprintln(w, "Example: relay-flow run cancel --ticket PAY-101 --reason "+`"operator request"`)
446
+ case "run backfill-owner":
447
+ fmt.Fprintln(w, "Usage: relay-flow run backfill-owner --repo <name> --ticket <key> --workflow <name>")
448
+ fmt.Fprintln(w, "\nExplicitly add adapter-owned provenance to a legacy workflow claim.")
449
+ fmt.Fprintln(w, "This never removes wf: labels, comments, or history and is not automatic routing.")
450
+ fmt.Fprintln(w, "Flags:")
451
+ fmt.Fprintln(w, " --repo <name> Registered repository containing the claim (required).")
452
+ fmt.Fprintln(w, " --ticket <key> Task ticket key (required).")
453
+ fmt.Fprintln(w, " --workflow <name> Claimed workflow name (required).")
454
+ fmt.Fprintln(w, "Example: relay-flow run backfill-owner --repo payments --ticket PAY-101 --workflow basicFlow")
421
455
  default:
422
456
  fmt.Fprintf(os.Stderr, "unknown help target: %s\n", name)
423
457
  return exitUsage
@@ -630,6 +664,10 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
630
664
  fmt.Fprintln(os.Stderr, "init: harness plugin: "+err.Error())
631
665
  return exitFail
632
666
  }
667
+ if names[0] == "jira" {
668
+ harnessDefaults["initial"] = harness.JiraInitialPrompt
669
+ harnessDefaults["feedback"] = harness.JiraFeedbackPrompt
670
+ }
633
671
  if !*force || !configExists {
634
672
  cfg.HarnessConfig = harnessDefaults
635
673
  } else {
@@ -689,7 +727,7 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
689
727
  fmt.Fprintln(os.Stderr, "init: "+err.Error())
690
728
  return exitFail
691
729
  }
692
- fmt.Println("Repository setup complete. The server is running; stop it with `relay-flow stop`.")
730
+ fmt.Println("Repository setup complete. The server is running; stop it with `rf stop`.")
693
731
  }
694
732
  } else if err := config.SaveMachine(p.Config, cfg); err != nil {
695
733
  fmt.Fprintln(os.Stderr, err)
@@ -821,11 +859,17 @@ func cmdServe(p paths.Paths, args []string) int {
821
859
  fs := flag.NewFlagSet("serve", flag.ContinueOnError)
822
860
  recover := fs.Bool("recover", false, "treat execution state as lost and rebuild from the task system")
823
861
  debug := fs.Bool("debug", false, "enable debug logging (overrides RELAY_FLOW_LOG_LEVEL)")
824
- background := fs.Bool("background", false, "start the server detached and return after readiness")
862
+ foreground := fs.Bool("foreground", false, "run the server in the current process until stop or signal")
863
+ background := fs.Bool("background", false, "compatibility spelling for the default detached mode")
825
864
  if err := fs.Parse(args); err != nil {
826
865
  return exitUsage
827
866
  }
828
- if *background {
867
+ child := os.Getenv(serveChildEnvironment) == "1"
868
+ if *foreground && *background {
869
+ fmt.Fprintln(os.Stderr, "serve: --foreground and --background cannot be used together")
870
+ return exitUsage
871
+ }
872
+ if !*foreground && !child {
829
873
  if err := startBackgroundServe(p, *recover, *debug); err != nil {
830
874
  fmt.Fprintln(os.Stderr, err)
831
875
  return exitFail
@@ -833,6 +877,12 @@ func cmdServe(p paths.Paths, args []string) int {
833
877
  fmt.Println("Relay-flow server started")
834
878
  return exitOK
835
879
  }
880
+ if runningAsMain {
881
+ if err := anchorForegroundServe(p.Root); err != nil {
882
+ fmt.Fprintln(os.Stderr, err)
883
+ return exitFail
884
+ }
885
+ }
836
886
  logCloser, err := logging.Setup(p.ServerLog, logging.Options{
837
887
  Debug: *debug,
838
888
  Env: os.Getenv("RELAY_FLOW_LOG_LEVEL"),
@@ -858,6 +908,9 @@ var (
858
908
  )
859
909
 
860
910
  func startBackgroundServe(p paths.Paths, recover, debug bool) error {
911
+ if err := ensureStableServeDirectory(p.Root); err != nil {
912
+ return fmt.Errorf("serve --background: prepare working directory: %w", err)
913
+ }
861
914
  client := server.NewClient(p.Socket)
862
915
  if serverResponding(client, 200*time.Millisecond) {
863
916
  return fmt.Errorf("serve --background: server is already running")
@@ -869,36 +922,57 @@ func startBackgroundServe(p paths.Paths, recover, debug bool) error {
869
922
  }
870
923
 
871
924
  var (
872
- cmd *exec.Cmd
873
- wait chan error
874
- observedOwner = lockHeld
875
- devNull *os.File
925
+ cmd *exec.Cmd
926
+ wait chan error
927
+ testServeCancel chan struct{}
928
+ observedOwner = lockHeld
929
+ devNull *os.File
876
930
  )
877
931
  if !lockHeld {
878
- executable, resolveErr := os.Executable()
879
- if resolveErr != nil {
880
- return fmt.Errorf("serve --background: resolve executable: %w", resolveErr)
881
- }
882
- childArgs := []string{"serve"}
883
- if recover {
884
- childArgs = append(childArgs, "--recover")
885
- }
886
- if debug {
887
- childArgs = append(childArgs, "--debug")
888
- }
889
- devNull, err = os.OpenFile(os.DevNull, os.O_RDWR, 0)
890
- if err != nil {
891
- return fmt.Errorf("serve --background: open %s: %w", os.DevNull, err)
892
- }
893
- defer devNull.Close()
894
- cmd = exec.Command(executable, childArgs...)
895
- cmd.Stdin, cmd.Stdout, cmd.Stderr = devNull, devNull, devNull
896
- cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
897
- if err := cmd.Start(); err != nil {
898
- return fmt.Errorf("serve --background: start: %w; see %s", err, p.ServerLog)
899
- }
900
932
  wait = make(chan error, 1)
901
- go func() { wait <- cmd.Wait() }()
933
+ if !runningAsMain {
934
+ // The run(args, stdin) seam is used by in-process tests. A Go test
935
+ // executable is not the relay-flow CLI entrypoint, so spawning
936
+ // os.Executable here would recursively run the test suite. Keep the
937
+ // production path detached while using the same serveRoot path in
938
+ // that seam.
939
+ serveCtx := &inProcessServeContext{Context: context.Background(), done: make(chan struct{})}
940
+ testServeCancel = serveCtx.done
941
+ go func() {
942
+ logCloser, logErr := logging.Setup(p.ServerLog, logging.Options{
943
+ Debug: debug,
944
+ Env: os.Getenv("RELAY_FLOW_LOG_LEVEL"),
945
+ })
946
+ if logErr != nil {
947
+ wait <- logErr
948
+ return
949
+ }
950
+ defer logCloser.Close()
951
+ wait <- serveRoot(serveCtx, p, recover)
952
+ }()
953
+ } else {
954
+ executable, resolveErr := os.Executable()
955
+ if resolveErr != nil {
956
+ return fmt.Errorf("serve --background: resolve executable: %w", resolveErr)
957
+ }
958
+ childArgs := serveChildArgs(recover, debug)
959
+ devNull, err = os.OpenFile(os.DevNull, os.O_RDWR, 0)
960
+ if err != nil {
961
+ return fmt.Errorf("serve --background: open %s: %w", os.DevNull, err)
962
+ }
963
+ defer devNull.Close()
964
+ cmd = exec.Command(executable, childArgs...)
965
+ cmd.Dir = p.Root
966
+ childEnvironment := setEnvironment(os.Environ(), serveChildEnvironment, "1")
967
+ childEnvironment = setEnvironment(childEnvironment, "PWD", p.Root)
968
+ cmd.Env = setEnvironment(childEnvironment, "RELAY_FLOW_HOME", p.Root)
969
+ cmd.Stdin, cmd.Stdout, cmd.Stderr = devNull, devNull, devNull
970
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
971
+ if err := cmd.Start(); err != nil {
972
+ return fmt.Errorf("serve --background: start: %w; see %s", err, p.ServerLog)
973
+ }
974
+ go func() { wait <- cmd.Wait() }()
975
+ }
902
976
  }
903
977
 
904
978
  ticker := time.NewTicker(backgroundServePollInterval)
@@ -908,6 +982,10 @@ func startBackgroundServe(p paths.Paths, recover, debug bool) error {
908
982
  return nil
909
983
  }
910
984
  if !time.Now().Before(deadline) {
985
+ if testServeCancel != nil {
986
+ close(testServeCancel)
987
+ testServeCancel = nil
988
+ }
911
989
  if cmd != nil && wait != nil {
912
990
  _ = cmd.Process.Kill()
913
991
  <-wait
@@ -925,6 +1003,7 @@ func startBackgroundServe(p paths.Paths, recover, debug bool) error {
925
1003
  }
926
1004
  select {
927
1005
  case childErr := <-wait:
1006
+ testServeCancel = nil
928
1007
  held, lockErr := serverLockHeld(p.Lock)
929
1008
  if lockErr != nil {
930
1009
  return fmt.Errorf("serve --background: inspect server lock: %w", lockErr)
@@ -943,6 +1022,80 @@ func startBackgroundServe(p paths.Paths, recover, debug bool) error {
943
1022
  }
944
1023
  }
945
1024
 
1025
+ func serveChildArgs(recover, debug bool) []string {
1026
+ args := []string{"serve"}
1027
+ if recover {
1028
+ args = append(args, "--recover")
1029
+ }
1030
+ if debug {
1031
+ args = append(args, "--debug")
1032
+ }
1033
+ return args
1034
+ }
1035
+
1036
+ type inProcessServeContext struct {
1037
+ context.Context
1038
+ done chan struct{}
1039
+ }
1040
+
1041
+ func (c *inProcessServeContext) Done() <-chan struct{} {
1042
+ return c.done
1043
+ }
1044
+
1045
+ func (c *inProcessServeContext) Err() error {
1046
+ select {
1047
+ case <-c.done:
1048
+ return context.Canceled
1049
+ default:
1050
+ return nil
1051
+ }
1052
+ }
1053
+
1054
+ func setEnvironment(environment []string, key, value string) []string {
1055
+ prefix := key + "="
1056
+ out := make([]string, 0, len(environment)+1)
1057
+ for _, entry := range environment {
1058
+ if strings.HasPrefix(entry, prefix) {
1059
+ continue
1060
+ }
1061
+ out = append(out, entry)
1062
+ }
1063
+ return append(out, prefix+value)
1064
+ }
1065
+
1066
+ func ensureStableServeDirectory(root string) error {
1067
+ if err := os.MkdirAll(root, 0o700); err != nil {
1068
+ return err
1069
+ }
1070
+ if err := os.Chmod(root, 0o700); err != nil {
1071
+ return err
1072
+ }
1073
+ info, err := os.Stat(root)
1074
+ if err != nil {
1075
+ return err
1076
+ }
1077
+ if !info.IsDir() {
1078
+ return fmt.Errorf("%s is not a directory", root)
1079
+ }
1080
+ return nil
1081
+ }
1082
+
1083
+ func anchorForegroundServe(root string) error {
1084
+ if err := ensureStableServeDirectory(root); err != nil {
1085
+ return err
1086
+ }
1087
+ if err := os.Chdir(root); err != nil {
1088
+ return fmt.Errorf("anchor working directory at %s: %w", root, err)
1089
+ }
1090
+ if err := os.Setenv("PWD", root); err != nil {
1091
+ return fmt.Errorf("set working directory environment at %s: %w", root, err)
1092
+ }
1093
+ if err := os.Setenv("RELAY_FLOW_HOME", root); err != nil {
1094
+ return fmt.Errorf("set relay-flow home environment at %s: %w", root, err)
1095
+ }
1096
+ return nil
1097
+ }
1098
+
946
1099
  func serverLockHeld(path string) (bool, error) {
947
1100
  f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
948
1101
  if err != nil {
@@ -1400,6 +1553,23 @@ func cmdRun(c *server.Client, args []string) int {
1400
1553
  return exitFail
1401
1554
  }
1402
1555
  return exitOK
1556
+ case "backfill-owner":
1557
+ fs := flag.NewFlagSet("run backfill-owner", flag.ContinueOnError)
1558
+ repoName := fs.String("repo", "", "registered repository name")
1559
+ ticket := fs.String("ticket", "", "ticket key")
1560
+ workflowName := fs.String("workflow", "", "workflow name")
1561
+ if err := fs.Parse(args[1:]); err != nil {
1562
+ return exitUsage
1563
+ }
1564
+ if *repoName == "" || *ticket == "" || *workflowName == "" {
1565
+ fmt.Fprintln(os.Stderr, "run backfill-owner: --repo, --ticket, and --workflow are required")
1566
+ return exitUsage
1567
+ }
1568
+ if err := c.BackfillClaimOwner(context.Background(), *repoName, *ticket, *workflowName); err != nil {
1569
+ fmt.Fprintln(os.Stderr, err)
1570
+ return exitFail
1571
+ }
1572
+ return exitOK
1403
1573
  }
1404
1574
  usage(os.Stderr)
1405
1575
  return exitUsage
@@ -64,6 +64,17 @@ func TestScopedHelpCoversAllCommandLevels(t *testing.T) {
64
64
  if !strings.Contains(root, "Use relay-flow <command> --help for command-specific details.") {
65
65
  t.Fatalf("root help missing scoped-help guidance:\n%s", root)
66
66
  }
67
+ for _, want := range []string{
68
+ "Plain serve starts detached",
69
+ "Unix-socket readiness",
70
+ "serve --foreground",
71
+ "blocking process-supervisor/development mode",
72
+ "server.log",
73
+ } {
74
+ if !strings.Contains(root, want) {
75
+ t.Fatalf("root help missing serve startup guidance %q:\n%s", want, root)
76
+ }
77
+ }
67
78
  }
68
79
 
69
80
  func TestInitHelpDocumentsTemporalFlags(t *testing.T) {
@@ -337,7 +337,7 @@ func standaloneRepoServerHint(err error) error {
337
337
  message := err.Error()
338
338
  for _, marker := range []string{"dial unix", "connect: no such file or directory", "connection refused"} {
339
339
  if strings.Contains(message, marker) {
340
- return fmt.Errorf("%w; start the server with `relay-flow serve --background` (or use the guided init path)", err)
340
+ return fmt.Errorf("%w; start the server with `rf serve` (or use the guided init path; use `relay-flow serve --foreground` when a blocking process is required)", err)
341
341
  }
342
342
  }
343
343
  return err
@@ -496,7 +496,7 @@ func TestRunOnboardingSpinner(t *testing.T) {
496
496
 
497
497
  func TestStandaloneRepoServerHint(t *testing.T) {
498
498
  err := standaloneRepoServerHint(errors.New("server call GET /repos: dial unix /tmp/server.sock: connect: no such file or directory"))
499
- if err == nil || !containsAll(err.Error(), "serve --background", "guided init") {
499
+ if err == nil || !containsAll(err.Error(), "rf serve", "guided init") {
500
500
  t.Fatalf("server hint = %v", err)
501
501
  }
502
502
  original := errors.New("server bad request")
@@ -839,6 +839,18 @@ func (s *scenarioTaskSystem) CompileFilter(config.RawValues) (func(task.Ticket)
839
839
  return func(t task.Ticket) bool { return t.Key == scenarioTicket }, nil
840
840
  }
841
841
 
842
+ func (s *scenarioTaskSystem) CompileOwnershipFilter(config.RawValues) (func(task.Ticket) bool, error) {
843
+ return func(task.Ticket) bool { return true }, nil
844
+ }
845
+
846
+ func (s *scenarioTaskSystem) ValidateOwnership(context.Context, task.TicketRef, string, config.RawValues) error {
847
+ return nil
848
+ }
849
+
850
+ func (s *scenarioTaskSystem) ClaimIfOwned(ctx context.Context, ref task.TicketRef, workflowName string, _ config.RawValues) error {
851
+ return s.Claim(ctx, ref, workflowName)
852
+ }
853
+
842
854
  func (s *scenarioTaskSystem) Claim(_ context.Context, ref task.TicketRef, workflowName string) error {
843
855
  s.log.add("claim:" + ref.Key + ":" + workflowName)
844
856
  s.mu.Lock()
@@ -1200,19 +1212,23 @@ func (h *scenarioHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command,
1200
1212
  h.sessions[spec.Title] = harness.Session{ID: "session-" + spec.Title, Title: spec.Title}
1201
1213
  h.mu.Unlock()
1202
1214
  h.log.add("harness-launched:" + spec.Title)
1215
+ env := map[string]string{
1216
+ "RELAY_FLOW_RUN_ID": string(spec.RunID),
1217
+ "RELAY_FLOW_WORKFLOW": spec.Workflow,
1218
+ "RELAY_FLOW_REPO": spec.RepoName,
1219
+ "RELAY_FLOW_TICKET": spec.Ticket,
1220
+ "RELAY_FLOW_NODE": spec.Node,
1221
+ "RELAY_FLOW_NODE_TYPE": string(spec.NodeType),
1222
+ "RELAY_FLOW_NUDGE_PROMPT": spec.NudgePrompt,
1223
+ "RELAY_FLOW_NEXT_STEPS_JSON": routesJSON(spec.NextSteps),
1224
+ }
1225
+ if spec.NodeType == workflow.NodeHITL {
1226
+ env["RELAY_FLOW_AUTO_REJECT"] = fmt.Sprintf("%t", spec.AutoReject)
1227
+ }
1203
1228
  return runner.Command{
1204
1229
  Executable: "fake-harness",
1205
1230
  Args: []string{"opaque", spec.Prompt},
1206
- Env: map[string]string{
1207
- "RELAY_FLOW_RUN_ID": string(spec.RunID),
1208
- "RELAY_FLOW_WORKFLOW": spec.Workflow,
1209
- "RELAY_FLOW_REPO": spec.RepoName,
1210
- "RELAY_FLOW_TICKET": spec.Ticket,
1211
- "RELAY_FLOW_NODE": spec.Node,
1212
- "RELAY_FLOW_NODE_TYPE": string(spec.NodeType),
1213
- "RELAY_FLOW_NUDGE_PROMPT": spec.NudgePrompt,
1214
- "RELAY_FLOW_NEXT_STEPS_JSON": routesJSON(spec.NextSteps),
1215
- },
1231
+ Env: env,
1216
1232
  }, nil
1217
1233
  }
1218
1234
 
@@ -541,6 +541,17 @@ func workflowSubmissionValidator(repoReg *repo.Registry, rnr runner.Runner, hrn
541
541
  if _, err := rp.TaskSystem.CompileFilter(wf.TaskConfig); err != nil {
542
542
  return fmt.Errorf("workflow %q repo %q: compile filter: %w", wf.Name, repoName, err)
543
543
  }
544
+ capabilities, ok := rp.TaskSystem.(task.OwnershipCapabilities)
545
+ if !ok {
546
+ return fmt.Errorf("workflow %q repo %q: task system lacks required ownership capabilities", wf.Name, repoName)
547
+ }
548
+ ownership, err := capabilities.CompileOwnershipFilter(wf.TaskConfig)
549
+ if err != nil {
550
+ return fmt.Errorf("workflow %q repo %q: compile ownership filter: %w", wf.Name, repoName, err)
551
+ }
552
+ if ownership == nil {
553
+ return fmt.Errorf("workflow %q repo %q: ownership compiler returned a nil matcher", wf.Name, repoName)
554
+ }
544
555
  for nodeName, node := range wf.Nodes {
545
556
  if (node.Type != workflow.NodeAgent && node.Type != workflow.NodeHITL) || node.Agent == "" {
546
557
  continue
@@ -650,6 +661,15 @@ func (d *serveDeps) RestartRun(ctx context.Context, ticket string) (runsvc.Run,
650
661
  func (d *serveDeps) CancelRun(ctx context.Context, ticket, reason string) error {
651
662
  return d.runManager.CancelByTicket(ctx, ticket, reason)
652
663
  }
664
+ func (d *serveDeps) BackfillClaimOwner(ctx context.Context, repoName, ticket, workflowName string) error {
665
+ if err := d.runManager.BackfillClaimOwner(ctx, repoName, ticket, workflowName); err != nil {
666
+ if errors.Is(err, task.ErrOwnershipMismatch) {
667
+ return fmt.Errorf("%w: %v", server.ErrConflict, err)
668
+ }
669
+ return err
670
+ }
671
+ return nil
672
+ }
653
673
 
654
674
  func (d *serveDeps) SubmitReport(ctx context.Context, rep runsvc.ReportRequest) (runsvc.ReportAck, error) {
655
675
  return d.engine.SubmitReport(ctx, rep)
@@ -720,15 +740,21 @@ func handleBatch(runManager *runsvc.RunManager) repo.BatchHandler {
720
740
  case err != nil:
721
741
  var amb *router.AmbiguousError
722
742
  var inv *router.InvalidClaimError
743
+ var ownerMismatch *router.ClaimOwnerMismatchError
723
744
  outcome := "error"
745
+ workflowName := ""
724
746
  switch {
725
747
  case errors.As(err, &amb):
726
748
  outcome = "ambiguous"
727
749
  case errors.As(err, &inv):
728
750
  outcome = "invalid-claim"
751
+ workflowName = inv.Workflow
752
+ case errors.As(err, &ownerMismatch):
753
+ outcome = "claim-owner-mismatch"
754
+ workflowName = ownerMismatch.Workflow
729
755
  }
730
756
  slog.Info("route outcome",
731
- "repo", rp.Name, "ticket", ticket.Key, "outcome", outcome, "error", err)
757
+ "repo", rp.Name, "ticket", ticket.Key, "workflow", workflowName, "outcome", outcome, "error", err)
732
758
  continue
733
759
  }
734
760
  routeOutcome := "claimed"
@@ -42,21 +42,19 @@ taskConfig:
42
42
  # parentStatus: In Progress
43
43
  # taskStatus: In Progress
44
44
 
45
- # Task-system text templates. The fixed report contract is appended by
46
- # relay-flow and is not changed by these templates.
45
+ # Jira mailbox descriptions include the canonical report through {{report}}.
47
46
  templates:
48
47
  mailboxDescription: |-
49
- Parent ticket: {{ticket}}
50
- Workflow: {{workflow}}
51
- Node: {{node}}
52
- Node type: {{nodeType}}
53
- Agent: {{agent}}
54
- Mailbox: {{mailbox}}
55
-
56
- Node work:
48
+ {{ticket}} / {{node}} — {{nodeType}} — {{agent}}
57
49
  {{nodeDescription}}
58
50
 
59
- Read this mailbox's comments for feedback from previous nodes.
51
+ Success routes:
52
+ {{successRoutes}}
53
+
54
+ Failure routes:
55
+ {{failureRoutes}}
56
+
57
+ {{report}}
60
58
 
61
59
  summaryComment: |-
62
60
  Summary for {{node}}
@@ -80,14 +78,19 @@ harnessPlugin: opencode
80
78
  harnessConfig:
81
79
  # OpenCode supports initial, feedback, and hitl templates.
82
80
  initial: |-
83
- Task system: {{taskSystem}}
84
- Use the {{taskSystem}} tools to read the parent ticket {{ticket}}.
81
+ Read the description of your assigned Jira task:
82
+ acli jira workitem view "{{mailbox}}" --fields "summary,description" --json
85
83
 
86
- Your mailbox is {{mailbox}}. Read its description and comments for node instructions and feedback.
84
+ Follow the work instructions, valid routes, and report format in that description.
87
85
 
88
86
  feedback: |-
89
- New feedback was added to the comments section of your mailbox subtask {{mailbox}}. Read it.
87
+ Continue {{node}} from the latest update on your assigned Jira task:
88
+ acli jira workitem comment list --key "{{mailbox}}" --limit 1 --order "-created" --json
90
89
 
90
+ # A node needing parent details can request this additional instruction
91
+ # in its node description (it is not included by default):
92
+ # Read the parent Jira ticket for this step:
93
+ # acli jira workitem view "{{ticket}}" --fields "summary,description" --json
91
94
  hitl: |-
92
95
  Return the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval.
93
96
 
@@ -135,12 +138,13 @@ repos:
135
138
  # harnessPlugin: pi
136
139
  # harnessConfig:
137
140
  # initial: |
138
- # Task system: {{taskSystem}}
139
- # Use the {{taskSystem}} tools to read the parent ticket {{ticket}}.
141
+ # Read the description of your assigned Jira task:
142
+ # acli jira workitem view "{{mailbox}}" --fields "summary,description" --json
140
143
  #
141
- # Your mailbox is {{mailbox}}. Read its description and comments for node instructions and feedback.
144
+ # Follow the work instructions, valid routes, and report format in that description.
142
145
  # feedback: |
143
- # New feedback was added to the comments section of your mailbox subtask {{mailbox}}. Read it.
146
+ # Continue {{node}} from the latest update on your assigned Jira task:
147
+ # acli jira workitem comment list --key "{{mailbox}}" --limit 1 --order "-created" --json
144
148
  #
145
149
  # Pi accepts only initial and feedback harness templates. Pi workflow nodes
146
150
  # may use agent: default or a native project prompt template in
@@ -56,6 +56,9 @@ nodes:
56
56
  type: agent
57
57
  agent: build
58
58
  description: |
59
+ Read the parent Jira ticket for this step:
60
+ acli jira workitem view "{{ticket}}" --fields "summary,description" --json
61
+
59
62
  Implement the parent ticket in the ticket worktree.
60
63
  Run the relevant tests and verification commands.
61
64
  nudgePrompt: |
@@ -80,7 +80,11 @@ func (a *Activities) EnsureMailboxes(ctx context.Context, w run.Work, specs []ta
80
80
  if err != nil {
81
81
  return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
82
82
  }
83
- specs[i].Description = appendText(custom, specs[i].Description)
83
+ if data.Report != "" && strings.Contains(custom, data.Report) {
84
+ specs[i].Description = custom
85
+ } else {
86
+ specs[i].Description = appendText(custom, specs[i].Description)
87
+ }
84
88
  }
85
89
  return sys.EnsureMailboxes(ctx, w.Parent, w.Workflow, specs)
86
90
  }
@@ -488,26 +492,9 @@ func MailboxSpecForNode(wf *workflow.Workflow, ticketKey, name string, n workflo
488
492
  var b strings.Builder
489
493
  b.WriteString(`Required report format:
490
494
 
491
- STATUS: success | failure
492
- NEXT STEP: <one valid node name>
493
-
494
- SUMMARY:
495
- COMPLETED:
496
- COMMITS:
497
- NOT COMPLETED:
498
- ISSUES DISCOVERED:
499
- VERIFICATION:
500
- NOTES:
501
-
502
- FEEDBACK:
503
- REASON FOR NEXT STEP:
504
- REQUIRED ACTIONS:
505
- RELEVANT CONTEXT:
506
- EXPECTED RESULT:
495
+ ` + workflow.ReportFormat + `
507
496
 
508
- Every field is required; use None for an intentionally empty section. COMMITS must contain the relevant commit IDs or None.
509
-
510
- Node names identify workflow stages; they are not task-system statuses. STATUS describes whether the work at this node succeeded or failed, not the status of the parent or mailbox. NEXT STEP must name exactly one target listed below for that STATUS. Submit one report only: its SUMMARY is written to this current mailbox, while its FEEDBACK is written only to the selected next node's mailbox. For review nodes, put requested changes in FEEDBACK and select the node responsible for acting on them. Relay-flow and the task system own parent and mailbox status changes. When NEXT STEP is end, every FEEDBACK field must be None.`)
497
+ Node names identify workflow stages; they are not task-system statuses. STATUS describes whether the work at this node succeeded or failed, not the status of the parent or mailbox. NEXT STEP must name exactly one target listed below for that STATUS. Submit one report only: its SUMMARY is written to this current mailbox, while its FEEDBACK is written only to the selected next node's mailbox. For review nodes, put requested changes in FEEDBACK and select the node responsible for acting on them. Relay-flow and the task system own parent and mailbox status changes. When NEXT STEP is end, FEEDBACK must be None.`)
511
498
  writeRoutes := func(label string, routes []workflow.Route) {
512
499
  if len(routes) == 0 {
513
500
  return
@@ -535,7 +522,7 @@ Node names identify workflow stages; they are not task-system statuses. STATUS d
535
522
  Agent: n.Agent, NodeDescription: n.Description,
536
523
  NextSteps: nextStepsText(append(append([]workflow.Route{}, n.OnSuccess...), n.OnFailure...)),
537
524
  SuccessRoutes: successRoutes, FailureRoutes: failureRoutes,
538
- Mailbox: ticketKey + ":" + name,
525
+ Mailbox: ticketKey + ":" + name, Report: workflow.ReportFormat,
539
526
  },
540
527
  }
541
528
  }
@@ -569,7 +556,11 @@ func RenderMailboxSpecs(sys task.System, w run.Work, wf *workflow.Workflow) ([]t
569
556
  if err != nil {
570
557
  return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
571
558
  }
572
- specs[i].Description = appendText(custom, specs[i].Description)
559
+ if data.Report != "" && strings.Contains(custom, data.Report) {
560
+ specs[i].Description = custom
561
+ } else {
562
+ specs[i].Description = appendText(custom, specs[i].Description)
563
+ }
573
564
  }
574
565
  return specs, nil
575
566
  }