relay-flow 0.3.9-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 (46) 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/fakes_test.go +20 -0
  10. package/internal/execution/goworkflows/interpreter.go +1 -0
  11. package/internal/execution/goworkflows/ownership_test.go +259 -0
  12. package/internal/execution/goworkflows/recovery_test.go +81 -1
  13. package/internal/execution/temporal/interpreter.go +2 -1
  14. package/internal/execution/temporal/recovery.go +68 -36
  15. package/internal/harness/harness.go +1 -0
  16. package/internal/harness/opencode/opencode.go +5 -0
  17. package/internal/harness/opencode/opencode_test.go +17 -1
  18. package/internal/harness/opencode/repo_setup.go +1 -1
  19. package/internal/harness/pi/pi.go +6 -0
  20. package/internal/harness/pi/task_env_test.go +16 -0
  21. package/internal/recover/recover.go +30 -1
  22. package/internal/repo/binding_test.go +63 -0
  23. package/internal/repo/repo.go +47 -6
  24. package/internal/router/router.go +39 -5
  25. package/internal/router/router_test.go +67 -1
  26. package/internal/run/manager.go +90 -3
  27. package/internal/run/run_manager_test.go +74 -0
  28. package/internal/server/api_test.go +13 -0
  29. package/internal/server/client.go +7 -0
  30. package/internal/server/fixture_test.go +5 -0
  31. package/internal/server/server.go +32 -3
  32. package/internal/task/beads/beads.go +201 -3
  33. package/internal/task/beads/beads_test.go +172 -0
  34. package/internal/task/jira/effects_test.go +86 -0
  35. package/internal/task/jira/filters_test.go +103 -0
  36. package/internal/task/jira/helpers_test.go +11 -2
  37. package/internal/task/jira/jira.go +228 -4
  38. package/internal/task/jira/normalize.go +30 -15
  39. package/internal/task/jira/rest/client.go +17 -2
  40. package/internal/task/jira/transition_defaults_test.go +3 -1
  41. package/internal/task/task.go +66 -0
  42. package/internal/workflow/report.go +3 -0
  43. package/internal/workflow/report_test.go +16 -0
  44. package/internal/workflow/workflow.go +24 -0
  45. package/internal/workflow/workflow_test.go +58 -1
  46. 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
@@ -689,7 +723,7 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
689
723
  fmt.Fprintln(os.Stderr, "init: "+err.Error())
690
724
  return exitFail
691
725
  }
692
- fmt.Println("Repository setup complete. The server is running; stop it with `relay-flow stop`.")
726
+ fmt.Println("Repository setup complete. The server is running; stop it with `rf stop`.")
693
727
  }
694
728
  } else if err := config.SaveMachine(p.Config, cfg); err != nil {
695
729
  fmt.Fprintln(os.Stderr, err)
@@ -821,11 +855,17 @@ func cmdServe(p paths.Paths, args []string) int {
821
855
  fs := flag.NewFlagSet("serve", flag.ContinueOnError)
822
856
  recover := fs.Bool("recover", false, "treat execution state as lost and rebuild from the task system")
823
857
  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")
858
+ foreground := fs.Bool("foreground", false, "run the server in the current process until stop or signal")
859
+ background := fs.Bool("background", false, "compatibility spelling for the default detached mode")
825
860
  if err := fs.Parse(args); err != nil {
826
861
  return exitUsage
827
862
  }
828
- if *background {
863
+ child := os.Getenv(serveChildEnvironment) == "1"
864
+ if *foreground && *background {
865
+ fmt.Fprintln(os.Stderr, "serve: --foreground and --background cannot be used together")
866
+ return exitUsage
867
+ }
868
+ if !*foreground && !child {
829
869
  if err := startBackgroundServe(p, *recover, *debug); err != nil {
830
870
  fmt.Fprintln(os.Stderr, err)
831
871
  return exitFail
@@ -833,6 +873,12 @@ func cmdServe(p paths.Paths, args []string) int {
833
873
  fmt.Println("Relay-flow server started")
834
874
  return exitOK
835
875
  }
876
+ if runningAsMain {
877
+ if err := anchorForegroundServe(p.Root); err != nil {
878
+ fmt.Fprintln(os.Stderr, err)
879
+ return exitFail
880
+ }
881
+ }
836
882
  logCloser, err := logging.Setup(p.ServerLog, logging.Options{
837
883
  Debug: *debug,
838
884
  Env: os.Getenv("RELAY_FLOW_LOG_LEVEL"),
@@ -858,6 +904,9 @@ var (
858
904
  )
859
905
 
860
906
  func startBackgroundServe(p paths.Paths, recover, debug bool) error {
907
+ if err := ensureStableServeDirectory(p.Root); err != nil {
908
+ return fmt.Errorf("serve --background: prepare working directory: %w", err)
909
+ }
861
910
  client := server.NewClient(p.Socket)
862
911
  if serverResponding(client, 200*time.Millisecond) {
863
912
  return fmt.Errorf("serve --background: server is already running")
@@ -869,36 +918,57 @@ func startBackgroundServe(p paths.Paths, recover, debug bool) error {
869
918
  }
870
919
 
871
920
  var (
872
- cmd *exec.Cmd
873
- wait chan error
874
- observedOwner = lockHeld
875
- devNull *os.File
921
+ cmd *exec.Cmd
922
+ wait chan error
923
+ testServeCancel chan struct{}
924
+ observedOwner = lockHeld
925
+ devNull *os.File
876
926
  )
877
927
  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
928
  wait = make(chan error, 1)
901
- go func() { wait <- cmd.Wait() }()
929
+ if !runningAsMain {
930
+ // The run(args, stdin) seam is used by in-process tests. A Go test
931
+ // executable is not the relay-flow CLI entrypoint, so spawning
932
+ // os.Executable here would recursively run the test suite. Keep the
933
+ // production path detached while using the same serveRoot path in
934
+ // that seam.
935
+ serveCtx := &inProcessServeContext{Context: context.Background(), done: make(chan struct{})}
936
+ testServeCancel = serveCtx.done
937
+ go func() {
938
+ logCloser, logErr := logging.Setup(p.ServerLog, logging.Options{
939
+ Debug: debug,
940
+ Env: os.Getenv("RELAY_FLOW_LOG_LEVEL"),
941
+ })
942
+ if logErr != nil {
943
+ wait <- logErr
944
+ return
945
+ }
946
+ defer logCloser.Close()
947
+ wait <- serveRoot(serveCtx, p, recover)
948
+ }()
949
+ } else {
950
+ executable, resolveErr := os.Executable()
951
+ if resolveErr != nil {
952
+ return fmt.Errorf("serve --background: resolve executable: %w", resolveErr)
953
+ }
954
+ childArgs := serveChildArgs(recover, debug)
955
+ devNull, err = os.OpenFile(os.DevNull, os.O_RDWR, 0)
956
+ if err != nil {
957
+ return fmt.Errorf("serve --background: open %s: %w", os.DevNull, err)
958
+ }
959
+ defer devNull.Close()
960
+ cmd = exec.Command(executable, childArgs...)
961
+ cmd.Dir = p.Root
962
+ childEnvironment := setEnvironment(os.Environ(), serveChildEnvironment, "1")
963
+ childEnvironment = setEnvironment(childEnvironment, "PWD", p.Root)
964
+ cmd.Env = setEnvironment(childEnvironment, "RELAY_FLOW_HOME", p.Root)
965
+ cmd.Stdin, cmd.Stdout, cmd.Stderr = devNull, devNull, devNull
966
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
967
+ if err := cmd.Start(); err != nil {
968
+ return fmt.Errorf("serve --background: start: %w; see %s", err, p.ServerLog)
969
+ }
970
+ go func() { wait <- cmd.Wait() }()
971
+ }
902
972
  }
903
973
 
904
974
  ticker := time.NewTicker(backgroundServePollInterval)
@@ -908,6 +978,10 @@ func startBackgroundServe(p paths.Paths, recover, debug bool) error {
908
978
  return nil
909
979
  }
910
980
  if !time.Now().Before(deadline) {
981
+ if testServeCancel != nil {
982
+ close(testServeCancel)
983
+ testServeCancel = nil
984
+ }
911
985
  if cmd != nil && wait != nil {
912
986
  _ = cmd.Process.Kill()
913
987
  <-wait
@@ -925,6 +999,7 @@ func startBackgroundServe(p paths.Paths, recover, debug bool) error {
925
999
  }
926
1000
  select {
927
1001
  case childErr := <-wait:
1002
+ testServeCancel = nil
928
1003
  held, lockErr := serverLockHeld(p.Lock)
929
1004
  if lockErr != nil {
930
1005
  return fmt.Errorf("serve --background: inspect server lock: %w", lockErr)
@@ -943,6 +1018,80 @@ func startBackgroundServe(p paths.Paths, recover, debug bool) error {
943
1018
  }
944
1019
  }
945
1020
 
1021
+ func serveChildArgs(recover, debug bool) []string {
1022
+ args := []string{"serve"}
1023
+ if recover {
1024
+ args = append(args, "--recover")
1025
+ }
1026
+ if debug {
1027
+ args = append(args, "--debug")
1028
+ }
1029
+ return args
1030
+ }
1031
+
1032
+ type inProcessServeContext struct {
1033
+ context.Context
1034
+ done chan struct{}
1035
+ }
1036
+
1037
+ func (c *inProcessServeContext) Done() <-chan struct{} {
1038
+ return c.done
1039
+ }
1040
+
1041
+ func (c *inProcessServeContext) Err() error {
1042
+ select {
1043
+ case <-c.done:
1044
+ return context.Canceled
1045
+ default:
1046
+ return nil
1047
+ }
1048
+ }
1049
+
1050
+ func setEnvironment(environment []string, key, value string) []string {
1051
+ prefix := key + "="
1052
+ out := make([]string, 0, len(environment)+1)
1053
+ for _, entry := range environment {
1054
+ if strings.HasPrefix(entry, prefix) {
1055
+ continue
1056
+ }
1057
+ out = append(out, entry)
1058
+ }
1059
+ return append(out, prefix+value)
1060
+ }
1061
+
1062
+ func ensureStableServeDirectory(root string) error {
1063
+ if err := os.MkdirAll(root, 0o700); err != nil {
1064
+ return err
1065
+ }
1066
+ if err := os.Chmod(root, 0o700); err != nil {
1067
+ return err
1068
+ }
1069
+ info, err := os.Stat(root)
1070
+ if err != nil {
1071
+ return err
1072
+ }
1073
+ if !info.IsDir() {
1074
+ return fmt.Errorf("%s is not a directory", root)
1075
+ }
1076
+ return nil
1077
+ }
1078
+
1079
+ func anchorForegroundServe(root string) error {
1080
+ if err := ensureStableServeDirectory(root); err != nil {
1081
+ return err
1082
+ }
1083
+ if err := os.Chdir(root); err != nil {
1084
+ return fmt.Errorf("anchor working directory at %s: %w", root, err)
1085
+ }
1086
+ if err := os.Setenv("PWD", root); err != nil {
1087
+ return fmt.Errorf("set working directory environment at %s: %w", root, err)
1088
+ }
1089
+ if err := os.Setenv("RELAY_FLOW_HOME", root); err != nil {
1090
+ return fmt.Errorf("set relay-flow home environment at %s: %w", root, err)
1091
+ }
1092
+ return nil
1093
+ }
1094
+
946
1095
  func serverLockHeld(path string) (bool, error) {
947
1096
  f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
948
1097
  if err != nil {
@@ -1400,6 +1549,23 @@ func cmdRun(c *server.Client, args []string) int {
1400
1549
  return exitFail
1401
1550
  }
1402
1551
  return exitOK
1552
+ case "backfill-owner":
1553
+ fs := flag.NewFlagSet("run backfill-owner", flag.ContinueOnError)
1554
+ repoName := fs.String("repo", "", "registered repository name")
1555
+ ticket := fs.String("ticket", "", "ticket key")
1556
+ workflowName := fs.String("workflow", "", "workflow name")
1557
+ if err := fs.Parse(args[1:]); err != nil {
1558
+ return exitUsage
1559
+ }
1560
+ if *repoName == "" || *ticket == "" || *workflowName == "" {
1561
+ fmt.Fprintln(os.Stderr, "run backfill-owner: --repo, --ticket, and --workflow are required")
1562
+ return exitUsage
1563
+ }
1564
+ if err := c.BackfillClaimOwner(context.Background(), *repoName, *ticket, *workflowName); err != nil {
1565
+ fmt.Fprintln(os.Stderr, err)
1566
+ return exitFail
1567
+ }
1568
+ return exitOK
1403
1569
  }
1404
1570
  usage(os.Stderr)
1405
1571
  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"
@@ -73,6 +73,7 @@ type fakeTaskSystem struct {
73
73
  labels map[string][]string // mailbox key -> labels
74
74
  specs []task.MailboxSpec
75
75
  comments []recordedComment
76
+ hasCommentN int
76
77
  resets []string
77
78
  renderText func(task.TextKind, task.TextData) (string, error)
78
79
 
@@ -115,6 +116,18 @@ func (s *fakeTaskSystem) CompileFilter(config.RawValues) (func(task.Ticket) bool
115
116
  return func(task.Ticket) bool { return true }, nil
116
117
  }
117
118
 
119
+ func (s *fakeTaskSystem) CompileOwnershipFilter(config.RawValues) (func(task.Ticket) bool, error) {
120
+ return func(task.Ticket) bool { return true }, nil
121
+ }
122
+
123
+ func (s *fakeTaskSystem) ValidateOwnership(context.Context, task.TicketRef, string, config.RawValues) error {
124
+ return nil
125
+ }
126
+
127
+ func (s *fakeTaskSystem) ClaimIfOwned(ctx context.Context, ref task.TicketRef, workflowName string, _ config.RawValues) error {
128
+ return s.Claim(ctx, ref, workflowName)
129
+ }
130
+
118
131
  func (s *fakeTaskSystem) Claim(_ context.Context, ref task.TicketRef, wf string) error {
119
132
  s.log.add("claim:" + ref.Key + ":" + wf)
120
133
  return nil
@@ -208,6 +221,7 @@ func (s *fakeTaskSystem) CompleteMailbox(_ context.Context, mb task.Mailbox) err
208
221
 
209
222
  func (s *fakeTaskSystem) HasComment(_ context.Context, target task.Target, marker string) (bool, error) {
210
223
  s.mu.Lock()
224
+ s.hasCommentN++
211
225
  defer s.mu.Unlock()
212
226
  for _, c := range s.comments {
213
227
  if c.Marker == marker {
@@ -267,6 +281,12 @@ func (s *fakeTaskSystem) ResetForRecovery(_ context.Context, parent task.TicketR
267
281
  return nil
268
282
  }
269
283
 
284
+ func (s *fakeTaskSystem) hasCommentCount() int {
285
+ s.mu.Lock()
286
+ defer s.mu.Unlock()
287
+ return s.hasCommentN
288
+ }
289
+
270
290
  func (s *fakeTaskSystem) commentBodies(key string) []recordedComment {
271
291
  s.mu.Lock()
272
292
  defer s.mu.Unlock()
@@ -292,6 +292,7 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
292
292
  Ticket: start.Ticket.Key,
293
293
  Node: current,
294
294
  NodeType: node.Type,
295
+ AutoReject: node.AutoReject,
295
296
  Agent: node.Agent,
296
297
  Title: title,
297
298
  NudgePrompt: node.NudgePrompt,