relay-flow 0.0.1 → 0.2.0-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 (116) hide show
  1. package/README.md +148 -143
  2. package/cmd/relay-flow/commands_test.go +464 -0
  3. package/cmd/relay-flow/main.go +670 -180
  4. package/cmd/relay-flow/scenario_test.go +1135 -0
  5. package/cmd/relay-flow/serve.go +609 -0
  6. package/go.mod +69 -2
  7. package/go.sum +185 -0
  8. package/internal/config/config.go +88 -0
  9. package/internal/config/machine.go +99 -48
  10. package/internal/config/machine_test.go +248 -0
  11. package/internal/config/merge_test.go +118 -0
  12. package/internal/config/writeatomic.go +36 -0
  13. package/internal/config/writeatomic_test.go +98 -0
  14. package/internal/execution/goworkflows/activities.go +490 -0
  15. package/internal/execution/goworkflows/engine.go +487 -0
  16. package/internal/execution/goworkflows/engine_test.go +600 -0
  17. package/internal/execution/goworkflows/fakes_test.go +517 -0
  18. package/internal/execution/goworkflows/interpreter.go +605 -0
  19. package/internal/execution/goworkflows/logging_test.go +154 -0
  20. package/internal/execution/goworkflows/mailbox_test.go +423 -0
  21. package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
  22. package/internal/execution/goworkflows/node_runtime_test.go +486 -0
  23. package/internal/execution/goworkflows/projection.go +504 -0
  24. package/internal/execution/goworkflows/recovery_test.go +1092 -0
  25. package/internal/execution/goworkflows/retry_log_test.go +59 -0
  26. package/internal/execution/goworkflows/retry_projection_test.go +98 -0
  27. package/internal/harness/contract_test.go +169 -0
  28. package/internal/harness/factory.go +63 -0
  29. package/internal/harness/harness.go +41 -0
  30. package/internal/harness/opencode/opencode.go +166 -0
  31. package/internal/harness/opencode/opencode_test.go +50 -0
  32. package/internal/harness/plugin_selection_test.go +126 -0
  33. package/internal/identity/identity.go +37 -0
  34. package/internal/logging/logging.go +56 -0
  35. package/internal/logging/logging_test.go +116 -0
  36. package/internal/paths/paths.go +67 -0
  37. package/internal/recover/recover.go +115 -0
  38. package/internal/repo/poller.go +186 -0
  39. package/internal/repo/poller_test.go +327 -0
  40. package/internal/repo/repo.go +119 -0
  41. package/internal/repo/service.go +216 -0
  42. package/internal/repo/service_test.go +298 -0
  43. package/internal/retry/retry.go +118 -0
  44. package/internal/router/router.go +83 -0
  45. package/internal/router/router_test.go +144 -0
  46. package/internal/run/manager.go +108 -0
  47. package/internal/run/run.go +140 -0
  48. package/internal/run/run_identity_test.go +52 -0
  49. package/internal/run/run_manager_test.go +266 -0
  50. package/internal/runner/contract_test.go +221 -0
  51. package/internal/runner/factory.go +65 -0
  52. package/internal/runner/orca/orca.go +363 -170
  53. package/internal/runner/orca/orca_test.go +134 -160
  54. package/internal/runner/orca/orcacli/orcacli.go +215 -0
  55. package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
  56. package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
  57. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
  58. package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
  59. package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
  60. package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
  61. package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
  62. package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
  63. package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
  64. package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
  65. package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
  66. package/internal/runner/runner.go +47 -64
  67. package/internal/server/api_test.go +300 -0
  68. package/internal/server/client.go +192 -74
  69. package/internal/server/fixture_test.go +248 -0
  70. package/internal/server/server.go +425 -248
  71. package/internal/server/shutdown_test.go +116 -0
  72. package/internal/task/contract_test.go +223 -0
  73. package/internal/task/factory.go +103 -0
  74. package/internal/task/jira/acli/acli.go +306 -0
  75. package/internal/task/jira/acli/acli_test.go +208 -0
  76. package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
  77. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
  78. package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
  79. package/internal/task/jira/acli/testdata/search_success.json +1 -0
  80. package/internal/task/jira/filters_test.go +234 -0
  81. package/internal/task/jira/helpers_test.go +60 -0
  82. package/internal/task/jira/jira.go +507 -0
  83. package/internal/task/jira/normalize.go +101 -0
  84. package/internal/task/jira/testdata/acli_search.json +120 -0
  85. package/internal/task/jira/transition_defaults_test.go +156 -0
  86. package/internal/task/jira/validation_test.go +94 -0
  87. package/internal/task/task.go +84 -0
  88. package/internal/workflow/report.go +85 -0
  89. package/internal/workflow/report_test.go +259 -0
  90. package/internal/workflow/service.go +142 -0
  91. package/internal/workflow/store.go +136 -0
  92. package/internal/workflow/store_test.go +282 -0
  93. package/internal/workflow/workflow.go +342 -0
  94. package/internal/workflow/workflow_test.go +410 -0
  95. package/package.json +1 -1
  96. package/internal/acli/acli.go +0 -229
  97. package/internal/config/demo_test.go +0 -17
  98. package/internal/config/schema.go +0 -193
  99. package/internal/config/schema_test.go +0 -162
  100. package/internal/daemon/daemon.go +0 -218
  101. package/internal/daemon/daemon_test.go +0 -204
  102. package/internal/discovery/discovery.go +0 -122
  103. package/internal/discovery/discovery_test.go +0 -62
  104. package/internal/opencode/opencode.go +0 -26
  105. package/internal/orcacli/orcacli.go +0 -264
  106. package/internal/runner/orca/README.md +0 -64
  107. package/internal/runner/runner_test.go +0 -64
  108. package/internal/server/server_test.go +0 -195
  109. package/internal/tasks/jira/README.md +0 -69
  110. package/internal/tasks/jira/component_test.go +0 -16
  111. package/internal/tasks/jira/decode.go +0 -24
  112. package/internal/tasks/jira/jira.go +0 -231
  113. package/internal/tasks/jira/jira_test.go +0 -259
  114. package/internal/tasks/jira/jql_test.go +0 -16
  115. package/internal/tasks/tasks.go +0 -90
  116. package/internal/tasks/tasks_test.go +0 -91
@@ -1,193 +0,0 @@
1
- // Package config loads and validates relay-flow workflow YAML files.
2
- package config
3
-
4
- import (
5
- "bytes"
6
- "fmt"
7
- "os"
8
- "regexp"
9
- "strings"
10
-
11
- "gopkg.in/yaml.v3"
12
- )
13
-
14
- // StringList accepts either a scalar or a list of strings in YAML and
15
- // normalizes both forms to []string, so a single value can be written as
16
- // `closeOn: done` instead of `closeOn: [done]`.
17
- type StringList []string
18
-
19
- func (s *StringList) UnmarshalYAML(value *yaml.Node) error {
20
- if value.Kind == yaml.ScalarNode {
21
- if value.Tag != "!!str" {
22
- return fmt.Errorf("expected string or list of strings")
23
- }
24
- *s = []string{value.Value}
25
- return nil
26
- }
27
- var values []string
28
- if err := value.Decode(&values); err != nil {
29
- return err
30
- }
31
- *s = values
32
- return nil
33
- }
34
-
35
- // Has reports membership (case-insensitive).
36
- func (s StringList) Has(name string) bool {
37
- for _, value := range s {
38
- if strings.EqualFold(value, name) {
39
- return true
40
- }
41
- }
42
- return false
43
- }
44
-
45
- // AdapterSpec selects a pluggable adapter (tasks or runner) by type name.
46
- // Config is opaque to core: the adapter's factory unmarshals it strictly.
47
- type AdapterSpec struct {
48
- Type string `yaml:"type"`
49
- Config map[string]any `yaml:"config"`
50
- }
51
-
52
- // Node is one square on the board: a tracker state (When), the agent that
53
- // works tickets in that state, and the outcome edges to other nodes.
54
- type Node struct {
55
- // Agent is the OpenCode agent serving this node. Empty = terminal node
56
- // (no agent runs; must be listed in closeOn).
57
- Agent string `yaml:"agent"`
58
- // When is the tracker state string that routes tickets to this node
59
- // (poll-time condition). Unique across the file, case-insensitive.
60
- When string `yaml:"when"`
61
- // OnSuccess / OnFailure are the outcome edges: node names the ticket
62
- // moves to when its agent reports success/failure. Self-loops allowed.
63
- OnSuccess string `yaml:"onSuccess"`
64
- OnFailure string `yaml:"onFailure"`
65
- // NudgePrompt is sent into the ticket's existing terminal when it
66
- // lands back on this node (bounce). Supports {{ticket}} and {{node}}
67
- // placeholders; defaults to DefaultNudgePrompt.
68
- NudgePrompt string `yaml:"nudgePrompt"`
69
- }
70
-
71
- // DefaultNudgePrompt is used when a node declares no nudgePrompt.
72
- const DefaultNudgePrompt = "Ticket {{ticket}} is at node '{{node}}' again. Read the tracker's latest feedback, continue your work, and end your reply with the STATUS/SUMMARY block as before."
73
-
74
- // Config is one workflow file: name, adapters, nodes, closeOn.
75
- type Config struct {
76
- // Name is the workflow's identity: server registry key, claim-label
77
- // component (`wf:<name>`), CLI argument. CamelCase, unique per server.
78
- Name string `yaml:"name"`
79
- PollIntervalSeconds int `yaml:"pollIntervalSeconds"`
80
- // Tasks selects the ticket-system adapter (jira, beads, ...).
81
- Tasks AdapterSpec `yaml:"tasks"`
82
- // Runner selects the execution backend (orca, tmux, ...).
83
- Runner AdapterSpec `yaml:"runner"`
84
- // CloseOn lists terminal nodes: tickets reaching them get their
85
- // terminals closed. Nodes here must have no agent.
86
- CloseOn StringList `yaml:"closeOn"`
87
- // Nodes maps node names to their definitions. The graph: each agent
88
- // node's edges point at other nodes; terminal nodes have no agent.
89
- Nodes map[string]Node `yaml:"nodes"`
90
- }
91
-
92
- var namePattern = regexp.MustCompile(`^[a-z][A-Za-z0-9]*$`)
93
-
94
- // NodeForState returns the node whose When matches tracker state
95
- // (case-insensitive), or "" if none.
96
- func (c *Config) NodeForState(state string) string {
97
- for name, n := range c.Nodes {
98
- if strings.EqualFold(n.When, state) {
99
- return name
100
- }
101
- }
102
- return ""
103
- }
104
-
105
- // Validate cross-checks the graph so broken configs fail at submit, before
106
- // any goroutine starts.
107
- func (c *Config) Validate() error {
108
- if !namePattern.MatchString(c.Name) {
109
- return fmt.Errorf("name %q must be camelCase with no spaces", c.Name)
110
- }
111
- if strings.TrimSpace(c.Tasks.Type) == "" {
112
- return fmt.Errorf("tasks.type must not be empty")
113
- }
114
- if strings.TrimSpace(c.Runner.Type) == "" {
115
- return fmt.Errorf("runner.type must not be empty")
116
- }
117
- if len(c.Nodes) == 0 {
118
- return fmt.Errorf("nodes must not be empty")
119
- }
120
- if len(c.CloseOn) == 0 {
121
- return fmt.Errorf("closeOn must not be empty (terminal nodes that close ticket terminals)")
122
- }
123
- for _, co := range c.CloseOn {
124
- n, ok := c.Nodes[co]
125
- if !ok {
126
- return fmt.Errorf("closeOn: unknown node %q", co)
127
- }
128
- if n.Agent != "" {
129
- return fmt.Errorf("closeOn: node %q has an agent; closeOn nodes must be terminal (no agent)", co)
130
- }
131
- }
132
- seenWhen := map[string]string{}
133
- for name, n := range c.Nodes {
134
- if strings.TrimSpace(n.When) == "" {
135
- return fmt.Errorf("nodes[%s].when must not be empty", name)
136
- }
137
- key := strings.ToLower(strings.TrimSpace(n.When))
138
- if other, dup := seenWhen[key]; dup {
139
- return fmt.Errorf("nodes[%s].when %q duplicates nodes[%s].when", name, n.When, other)
140
- }
141
- seenWhen[key] = name
142
- if n.Agent == "" {
143
- // Agentless = human gate / pause node: no automation, no
144
- // edges required. Whether it also closes terminals is
145
- // controlled solely by closeOn.
146
- continue
147
- }
148
- if n.OnSuccess == "" {
149
- return fmt.Errorf("nodes[%s].onSuccess must not be empty (agent nodes need both outcome edges)", name)
150
- }
151
- if n.OnFailure == "" {
152
- return fmt.Errorf("nodes[%s].onFailure must not be empty (agent nodes need both outcome edges)", name)
153
- }
154
- if _, ok := c.Nodes[n.OnSuccess]; !ok {
155
- return fmt.Errorf("nodes[%s].onSuccess: unknown node %q", name, n.OnSuccess)
156
- }
157
- if _, ok := c.Nodes[n.OnFailure]; !ok {
158
- return fmt.Errorf("nodes[%s].onFailure: unknown node %q", name, n.OnFailure)
159
- }
160
- if n.NudgePrompt == "" {
161
- n.NudgePrompt = DefaultNudgePrompt
162
- c.Nodes[name] = n
163
- }
164
- }
165
- return nil
166
- }
167
-
168
- // Parse decodes and validates workflow YAML bytes. name is only used in
169
- // error messages.
170
- func Parse(name string, b []byte) (*Config, error) {
171
- var c Config
172
- dec := yaml.NewDecoder(bytes.NewReader(b))
173
- dec.KnownFields(true)
174
- if err := dec.Decode(&c); err != nil {
175
- return nil, fmt.Errorf("parse config %s: %w", name, err)
176
- }
177
- if c.PollIntervalSeconds <= 0 {
178
- c.PollIntervalSeconds = 15
179
- }
180
- if err := c.Validate(); err != nil {
181
- return nil, fmt.Errorf("config %s: %w", name, err)
182
- }
183
- return &c, nil
184
- }
185
-
186
- // Load reads and parses a workflow YAML file.
187
- func Load(path string) (*Config, error) {
188
- b, err := os.ReadFile(path)
189
- if err != nil {
190
- return nil, fmt.Errorf("read config %s: %w", path, err)
191
- }
192
- return Parse(path, b)
193
- }
@@ -1,162 +0,0 @@
1
- package config
2
-
3
- import (
4
- "strings"
5
- "testing"
6
- )
7
-
8
- const validYAML = `
9
- name: xyzTaskFlow
10
- pollIntervalSeconds: 15
11
- tasks:
12
- type: jira
13
- config:
14
- query: project = xyz
15
- issueTypes: [Task]
16
- runner:
17
- type: orca
18
- closeOn: [done]
19
- nodes:
20
- coding:
21
- agent: build
22
- when: "In Progress"
23
- onSuccess: reviewing
24
- onFailure: coding
25
- reviewing:
26
- agent: build
27
- when: "In Review"
28
- onSuccess: done
29
- onFailure: coding
30
- done:
31
- when: "Done"
32
- `
33
-
34
- func TestParseValid(t *testing.T) {
35
- cfg, err := Parse("test", []byte(validYAML))
36
- if err != nil {
37
- t.Fatalf("valid config rejected: %v", err)
38
- }
39
- if cfg.Name != "xyzTaskFlow" {
40
- t.Errorf("name = %q", cfg.Name)
41
- }
42
- if cfg.PollIntervalSeconds != 15 {
43
- t.Errorf("pollIntervalSeconds = %d", cfg.PollIntervalSeconds)
44
- }
45
- if cfg.Tasks.Type != "jira" || cfg.Runner.Type != "orca" {
46
- t.Errorf("tasks=%q runner=%q", cfg.Tasks.Type, cfg.Runner.Type)
47
- }
48
- if cfg.Tasks.Config["query"] != "project = xyz" {
49
- t.Errorf("opaque tasks config not preserved: %v", cfg.Tasks.Config)
50
- }
51
- if len(cfg.Nodes) != 3 {
52
- t.Fatalf("nodes = %v", cfg.Nodes)
53
- }
54
- n := cfg.Nodes["coding"]
55
- if n.Agent != "build" || n.When != "In Progress" || n.OnSuccess != "reviewing" || n.OnFailure != "coding" {
56
- t.Errorf("coding node = %+v", n)
57
- }
58
- if cfg.Nodes["done"].Agent != "" {
59
- t.Errorf("done should have no agent")
60
- }
61
- if len(cfg.CloseOn) != 1 || cfg.CloseOn[0] != "done" {
62
- t.Errorf("closeOn = %v", cfg.CloseOn)
63
- }
64
- }
65
-
66
- func TestParseDefaultPollInterval(t *testing.T) {
67
- yaml := strings.Replace(validYAML, "pollIntervalSeconds: 15\n", "", 1)
68
- cfg, err := Parse("test", []byte(yaml))
69
- if err != nil {
70
- t.Fatalf("%v", err)
71
- }
72
- if cfg.PollIntervalSeconds != 15 {
73
- t.Errorf("default pollIntervalSeconds = %d, want 15", cfg.PollIntervalSeconds)
74
- }
75
- }
76
-
77
- func TestCloseOnScalar(t *testing.T) {
78
- yaml := strings.Replace(validYAML, "closeOn: [done]", "closeOn: done", 1)
79
- cfg, err := Parse("test", []byte(yaml))
80
- if err != nil {
81
- t.Fatalf("%v", err)
82
- }
83
- if len(cfg.CloseOn) != 1 || cfg.CloseOn[0] != "done" {
84
- t.Errorf("closeOn = %v", cfg.CloseOn)
85
- }
86
- }
87
-
88
- func TestParseErrors(t *testing.T) {
89
- rep := func(old, new string) string { return strings.Replace(validYAML, old, new, 1) }
90
- cases := []struct {
91
- name string
92
- yaml string
93
- want string
94
- }{
95
- {"empty name", rep("name: xyzTaskFlow", "name: \"\""), "name"},
96
- {"bad name", rep("name: xyzTaskFlow", "name: My Flow"), "camelCase"},
97
- {"unknown top field", validYAML + "bogus: 1\n", "field bogus"},
98
- {"unknown node field", rep("onFailure: coding\n reviewing:", "onFailure: coding\n bogus: 1\n reviewing:"), "field bogus"},
99
- {"no tasks type", rep("type: jira", "type: \"\""), "tasks.type"},
100
- {"no runner type", rep("type: orca", "type: \"\""), "runner.type"},
101
- {"empty closeOn", rep("closeOn: [done]", "closeOn: []"), "closeOn"},
102
- {"closeOn unknown node", rep("closeOn: [done]", "closeOn: [nope]"), "closeOn"},
103
- {"closeOn node has agent", rep("closeOn: [done]", "closeOn: [coding]"), "closeOn"},
104
-
105
- {"edge target missing", rep("onSuccess: reviewing", "onSuccess: nowhere"), "onSuccess"},
106
- {"agent node missing onSuccess", rep(" onSuccess: reviewing\n", ""), "onSuccess"},
107
- {"agent node missing onFailure", rep(" onFailure: coding\n reviewing", " reviewing"), "onFailure"},
108
- {"dup when", rep("when: \"In Review\"", "when: \"In Progress\""), "duplicat"},
109
- {"empty when", rep("when: \"In Progress\"", "when: \"\""), "when"},
110
- {"no nodes", rep("nodes:\n", "nodes: {}\n#") + "", "nodes"},
111
- }
112
- for _, tc := range cases {
113
- t.Run(tc.name, func(t *testing.T) {
114
- yaml := tc.yaml
115
- if tc.name == "no nodes" {
116
- // strip the entire nodes block
117
- yaml = validYAML[:strings.Index(validYAML, "nodes:")]
118
- }
119
- _, err := Parse("test", []byte(yaml))
120
- if err == nil {
121
- t.Fatalf("expected error containing %q, got nil", tc.want)
122
- }
123
- if !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tc.want)) {
124
- t.Fatalf("error %q missing %q", err, tc.want)
125
- }
126
- })
127
- }
128
- }
129
-
130
- func TestSelfLoopEdgeAllowed(t *testing.T) {
131
- // coding.onFailure == coding: already in validYAML, must parse.
132
- if _, err := Parse("test", []byte(validYAML)); err != nil {
133
- t.Fatalf("%v", err)
134
- }
135
- }
136
-
137
- func TestNodeForState(t *testing.T) {
138
- cfg, err := Parse("test", []byte(validYAML))
139
- if err != nil {
140
- t.Fatalf("%v", err)
141
- }
142
- if got := cfg.NodeForState("in progress"); got != "coding" {
143
- t.Errorf("NodeForState(in progress) = %q, want coding", got)
144
- }
145
- if got := cfg.NodeForState("Done"); got != "done" {
146
- t.Errorf("NodeForState(Done) = %q, want done", got)
147
- }
148
- if got := cfg.NodeForState("Backlog"); got != "" {
149
- t.Errorf("NodeForState(Backlog) = %q, want empty", got)
150
- }
151
- }
152
-
153
- func TestDefaultNudgePrompt(t *testing.T) {
154
- cfg, err := Parse("test", []byte(validYAML))
155
- if err != nil {
156
- t.Fatalf("%v", err)
157
- }
158
- p := cfg.Nodes["coding"].NudgePrompt
159
- if !strings.Contains(p, "{{ticket}}") || !strings.Contains(p, "{{node}}") {
160
- t.Errorf("default nudgePrompt missing templates: %q", p)
161
- }
162
- }
@@ -1,218 +0,0 @@
1
- // Package daemon runs one workflow's poll loop: list tickets from the
2
- // tasks adapter, route each through the 3-way claim switch, and dispatch
3
- // agent sessions through the runner adapter. It contains no tracker- or
4
- // runner-specific logic — both arrive as interfaces.
5
- package daemon
6
-
7
- import (
8
- "context"
9
- "fmt"
10
- "log"
11
- "strings"
12
- "sync"
13
- "time"
14
-
15
- "github.com/rajpopat27/relay-flow/internal/config"
16
- "github.com/rajpopat27/relay-flow/internal/runner"
17
- "github.com/rajpopat27/relay-flow/internal/tasks"
18
- )
19
-
20
- // Daemon polls one workflow and dispatches tickets. Long-lived: one poll
21
- // goroutine per submitted workflow; dispatch/bounce run as short-lived
22
- // goroutines per ticket.
23
- type Daemon struct {
24
- cfg *config.Config
25
- tasks tasks.Tasks
26
- runner runner.Runner
27
- repoID string
28
- repoName string
29
- dryRun bool
30
-
31
- // nudged marks key → node for which a prompt/nudge was already
32
- // delivered, so each node visit prompts exactly once. Cleared when a
33
- // report moves the ticket (re-arming the next visit).
34
- nudgedMu sync.Mutex
35
- nudged map[string]string
36
-
37
- wg sync.WaitGroup // tracks dispatch/bounce goroutines; Wait blocks tests
38
- }
39
-
40
- // New builds a daemon for one validated workflow config.
41
- func New(cfg *config.Config, tk tasks.Tasks, rn runner.Runner, repoID, repoName string, dryRun bool) *Daemon {
42
- return &Daemon{
43
- cfg: cfg, tasks: tk, runner: rn,
44
- repoID: repoID, repoName: repoName, dryRun: dryRun,
45
- nudged: map[string]string{},
46
- }
47
- }
48
-
49
- // PollLoop ticks until ctx is cancelled (server shutdown/remove).
50
- func (d *Daemon) PollLoop(ctx context.Context) {
51
- interval := time.Duration(d.cfg.PollIntervalSeconds) * time.Second
52
- ticker := time.NewTicker(interval)
53
- defer ticker.Stop()
54
- d.PollOnce()
55
- for {
56
- select {
57
- case <-ctx.Done():
58
- return
59
- case <-ticker.C:
60
- d.PollOnce()
61
- }
62
- }
63
- }
64
-
65
- // Wait blocks until in-flight dispatch/bounce goroutines finish. Tests
66
- // call it after PollOnce; production never needs it.
67
- func (d *Daemon) Wait() { d.wg.Wait() }
68
-
69
- // PollOnce lists tickets once and routes each:
70
- //
71
- // claimed by another workflow → skip (cross-workflow mutex)
72
- // unmapped tracker state → log + skip
73
- // node in closeOn → runner.Close (terminal teardown)
74
- // claimed by me, not prompted → bounce: Find → Nudge (or respawn)
75
- // unclaimed → dispatch: Claim → Spawn
76
- func (d *Daemon) PollOnce() {
77
- found, err := d.tasks.List()
78
- if err != nil {
79
- log.Printf("poll %s: %v", d.cfg.Name, err)
80
- return
81
- }
82
- for _, t := range found {
83
- switch {
84
- case t.ClaimedBy != "" && t.ClaimedBy != d.cfg.Name:
85
- // Foreign workflow owns it — never touch.
86
- case t.Node == "":
87
- log.Printf("poll %s: %s at unmapped state, skipping", d.cfg.Name, t.Key)
88
- case d.cfg.CloseOn.Has(t.Node):
89
- log.Printf("poll %s: %s at terminal node %q, closing terminals", d.cfg.Name, t.Key, t.Node)
90
- if err := d.runner.Close(t); err != nil {
91
- log.Printf("poll %s: close %s: %v", d.cfg.Name, t.Key, err)
92
- }
93
- d.ClearNudged(t.Key)
94
- case d.cfg.Nodes[t.Node].Agent == "":
95
- // Human gate: no automation. Claim it so foreign workflows
96
- // leave it alone, then leave the ticket for the human.
97
- if t.ClaimedBy == "" {
98
- if err := d.tasks.Claim(t); err != nil {
99
- log.Printf("poll %s: claim %s (gate node): %v", d.cfg.Name, t.Key, err)
100
- }
101
- }
102
- case t.ClaimedBy == d.cfg.Name:
103
- d.wg.Add(1)
104
- go d.bounce(t)
105
- default:
106
- d.wg.Add(1)
107
- go d.dispatch(t)
108
- }
109
- }
110
- }
111
-
112
- // dispatch claims an unclaimed ticket and spawns its agent session.
113
- func (d *Daemon) dispatch(t tasks.Ticket) {
114
- defer d.wg.Done()
115
- node := d.cfg.Nodes[t.Node]
116
- if err := d.tasks.Claim(t); err != nil {
117
- log.Printf("dispatch %s: claim: %v", t.Key, err)
118
- return
119
- }
120
- prompt := initialPrompt(d.cfg, t.Node, t)
121
- env := map[string]string{
122
- "RELAY_FLOW_WORKFLOW": d.cfg.Name,
123
- "RELAY_FLOW_TICKET": t.Key,
124
- "RELAY_FLOW_NODE": t.Node,
125
- "RELAY_FLOW_AGENT": node.Agent,
126
- }
127
- if err := d.runner.Spawn(t, t.Node, node.Agent, prompt, env); err != nil {
128
- log.Printf("dispatch %s: spawn: %v", t.Key, err)
129
- return
130
- }
131
- d.markNudged(t.Key, t.Node)
132
- log.Printf("dispatch %s: spawned %q for node %q", t.Key, node.Agent, t.Node)
133
- }
134
-
135
- // bounce handles a ticket this workflow claimed but has no in-memory
136
- // record of (server restart): find the live terminal and nudge it, once
137
- // per node visit. No terminal → spawn fresh (claim already held).
138
- func (d *Daemon) bounce(t tasks.Ticket) {
139
- defer d.wg.Done()
140
- node := d.cfg.Nodes[t.Node]
141
- sess, ok, err := d.runner.Find(t, t.Node)
142
- if err != nil {
143
- log.Printf("bounce %s: find: %v", t.Key, err)
144
- return
145
- }
146
- if !ok {
147
- // No live session: marker is irrelevant — always respawn (a
148
- // terminal may have died after we marked it prompted).
149
- d.ClearNudged(t.Key)
150
- // Crash took the terminal with it: spawn a fresh session.
151
- prompt := initialPrompt(d.cfg, t.Node, t)
152
- env := map[string]string{
153
- "RELAY_FLOW_WORKFLOW": d.cfg.Name,
154
- "RELAY_FLOW_TICKET": t.Key,
155
- "RELAY_FLOW_NODE": t.Node,
156
- "RELAY_FLOW_AGENT": node.Agent,
157
- }
158
- if err := d.runner.Spawn(t, t.Node, node.Agent, prompt, env); err != nil {
159
- log.Printf("bounce %s: spawn: %v", t.Key, err)
160
- return
161
- }
162
- d.markNudged(t.Key, t.Node)
163
- log.Printf("bounce %s: no live session, spawned fresh for node %q", t.Key, t.Node)
164
- return
165
- }
166
- if d.nudgedNode(t.Key) == t.Node {
167
- return // session alive and already prompted for this visit
168
- }
169
- prompt := renderNudge(node.NudgePrompt, t.Key, t.Node)
170
- if err := d.runner.Nudge(sess, prompt); err != nil {
171
- log.Printf("bounce %s: nudge: %v (retry next poll)", t.Key, err)
172
- return
173
- }
174
- d.markNudged(t.Key, t.Node)
175
- log.Printf("bounce %s: nudged %q for node %q", t.Key, sess.Title, t.Node)
176
- }
177
-
178
- // ClearNudged drops the prompted marker for a ticket — called when a
179
- // report moves it to a new node, re-arming the next visit's nudge.
180
- func (d *Daemon) ClearNudged(key string) {
181
- d.nudgedMu.Lock()
182
- delete(d.nudged, key)
183
- d.nudgedMu.Unlock()
184
- }
185
-
186
- func (d *Daemon) markNudged(key, node string) {
187
- d.nudgedMu.Lock()
188
- d.nudged[key] = node
189
- d.nudgedMu.Unlock()
190
- }
191
-
192
- func (d *Daemon) nudgedNode(key string) string {
193
- d.nudgedMu.Lock()
194
- defer d.nudgedMu.Unlock()
195
- return d.nudged[key]
196
- }
197
-
198
- // initialPrompt tells the agent which ticket it owns and the STATUS/
199
- // SUMMARY handoff contract (statuses success|failure). The agent fetches
200
- // ticket details itself via acli — nothing is injected here so feedback
201
- // is always picked up fresh. Flattened to one line: the command is typed
202
- // into a pty via keystroke simulation.
203
- func initialPrompt(cfg *config.Config, nodeName string, t tasks.Ticket) string {
204
- prompt := fmt.Sprintf(
205
- "You have been assigned ticket %s. Run `acli jira workitem view %s --fields summary,description,comment --json` "+
206
- "to fetch its details and full comment history. When you are done, end your final message with exactly: "+
207
- "STATUS: <success or failure> SUMMARY: <one-line summary of what you did>",
208
- t.Key, t.Key)
209
- return strings.Join(strings.Fields(prompt), " ")
210
- }
211
-
212
- // renderNudge applies {{ticket}}/{{node}} templating and flattens to one
213
- // line (keystroke simulation submits on newline).
214
- func renderNudge(tmpl, ticket, node string) string {
215
- s := strings.ReplaceAll(tmpl, "{{ticket}}", ticket)
216
- s = strings.ReplaceAll(s, "{{node}}", node)
217
- return strings.Join(strings.Fields(s), " ")
218
- }