relay-flow 0.2.1-alpha → 0.2.2-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 (72) hide show
  1. package/README.md +155 -22
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +74 -0
  4. package/cmd/relay-flow/main.go +25 -0
  5. package/cmd/relay-flow/scenario_test.go +48 -12
  6. package/cmd/relay-flow/serve.go +4 -2
  7. package/examples/beads-workflow.yaml +74 -0
  8. package/examples/default-story-workflow.yaml +6 -6
  9. package/go.mod +2 -1
  10. package/go.sum +2 -0
  11. package/internal/config/config.go +13 -2
  12. package/internal/config/merge_test.go +18 -0
  13. package/internal/execution/goworkflows/activities.go +105 -56
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine_test.go +33 -15
  16. package/internal/execution/goworkflows/fakes_test.go +50 -4
  17. package/internal/execution/goworkflows/interpreter.go +36 -29
  18. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  19. package/internal/execution/goworkflows/node_runtime_test.go +30 -5
  20. package/internal/execution/goworkflows/recovery_test.go +2 -2
  21. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  22. package/internal/harness/contract_test.go +10 -0
  23. package/internal/harness/factory.go +25 -3
  24. package/internal/harness/harness.go +30 -4
  25. package/internal/harness/opencode/opencode.go +125 -10
  26. package/internal/harness/opencode/opencode_test.go +183 -0
  27. package/internal/harness/opencode/repo_setup.go +361 -0
  28. package/internal/harness/plugin_selection_test.go +5 -5
  29. package/internal/recover/recover.go +11 -6
  30. package/internal/repo/service.go +10 -0
  31. package/internal/repo/service_test.go +51 -2
  32. package/internal/run/run.go +6 -4
  33. package/internal/task/beads/bdcli/bdcli.go +323 -0
  34. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  35. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  36. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  37. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  38. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  39. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  40. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  41. package/internal/task/beads/bdcli/testdata/empty.json +1 -0
  42. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  43. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  44. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  45. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  46. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  47. package/internal/task/beads/beads.go +840 -0
  48. package/internal/task/beads/beads_test.go +609 -0
  49. package/internal/task/beads/comments_test.go +242 -0
  50. package/internal/task/beads/config_compatibility_test.go +163 -0
  51. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  52. package/internal/task/beads/repo_composition_test.go +232 -0
  53. package/internal/task/beads/runtime_config_test.go +81 -0
  54. package/internal/task/beads/status_compatibility_test.go +233 -0
  55. package/internal/task/beads/status_test.go +257 -0
  56. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  57. package/internal/task/beads/validation_test.go +110 -0
  58. package/internal/task/contract_test.go +10 -0
  59. package/internal/task/factory.go +37 -4
  60. package/internal/task/jira/auth.go +27 -1
  61. package/internal/task/jira/auth_test.go +54 -1
  62. package/internal/task/jira/filters_test.go +60 -0
  63. package/internal/task/jira/jira.go +171 -29
  64. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  65. package/internal/task/jira/rest/adf.go +119 -82
  66. package/internal/task/jira/rest/adf_test.go +60 -0
  67. package/internal/task/jira/rest/client_test.go +1 -1
  68. package/internal/task/jira/templates_test.go +118 -0
  69. package/internal/task/jira/transition_defaults_test.go +4 -2
  70. package/internal/task/task.go +32 -0
  71. package/internal/workflow/report_test.go +45 -0
  72. package/package.json +1 -1
@@ -41,6 +41,9 @@ var (
41
41
  func init() {
42
42
  task.Register(scenarioTaskPlugin, task.Factory{
43
43
  RequiredRepoKeys: func() []string { return nil },
44
+ DefaultConfig: func() config.RawValues {
45
+ return config.RawValues{"templates": map[string]any{"format": "alternate"}}
46
+ },
44
47
  TaskScopeKey: func(config.RawValues, config.RawValues) (string, error) {
45
48
  return "scenario-scope", nil
46
49
  },
@@ -67,16 +70,19 @@ func init() {
67
70
  }
68
71
  return scenarioFactoryRunner, nil
69
72
  })
70
- harness.Register(scenarioHarnessPlugin, func(config.RawValues) (harness.Harness, error) {
71
- scenarioFactoryMu.Lock()
72
- defer scenarioFactoryMu.Unlock()
73
- if scenarioFactoryHarness == nil {
74
- return nil, errors.New("scenario harness not configured")
75
- }
76
- if fake, ok := scenarioFactoryHarness.(*scenarioHarness); ok {
77
- fake.log.add("factory:harness:" + scenarioHarnessPlugin)
78
- }
79
- return scenarioFactoryHarness, nil
73
+ harness.Register(scenarioHarnessPlugin, harness.Factory{
74
+ DefaultConfig: func() config.RawValues { return config.RawValues{"runtime": "alternate"} },
75
+ New: func(config.RawValues) (harness.Harness, error) {
76
+ scenarioFactoryMu.Lock()
77
+ defer scenarioFactoryMu.Unlock()
78
+ if scenarioFactoryHarness == nil {
79
+ return nil, errors.New("scenario harness not configured")
80
+ }
81
+ if fake, ok := scenarioFactoryHarness.(*scenarioHarness); ok {
82
+ fake.log.add("factory:harness:" + scenarioHarnessPlugin)
83
+ }
84
+ return scenarioFactoryHarness, nil
85
+ },
80
86
  })
81
87
  }
82
88
 
@@ -194,7 +200,7 @@ func TestCompositionRootSelectsAlternatePluginsForDurableRun(t *testing.T) {
194
200
  })
195
201
 
196
202
  launch := hrn.launch("implement")
197
- wantPrompt := "Task system: " + scenarioTaskPlugin + "\nUse the " + scenarioTaskPlugin + " tools to read the parent ticket " + scenarioTicket + "."
203
+ wantPrompt := "initial taskSystem=" + scenarioTaskPlugin
198
204
  if !strings.Contains(launch.Prompt, wantPrompt) {
199
205
  t.Fatalf("selected task system did not reach launch prompt: %q", launch.Prompt)
200
206
  }
@@ -416,7 +422,7 @@ func TestScenarioLateRegisterAndLateSubmit(t *testing.T) {
416
422
  }
417
423
  repoService := repo.NewServiceWithRegistry(repo.ServiceConfig{
418
424
  ConfigPath: configPath, TaskPlugin: scenarioTaskPlugin, Runner: fr,
419
- Active: engine, Workflows: wfService.Registry(),
425
+ Harness: fh, Active: engine, Workflows: wfService.Registry(),
420
426
  }, reg)
421
427
  deps := &serveDeps{
422
428
  repos: repoService,
@@ -785,6 +791,7 @@ type scenarioTaskSystem struct {
785
791
  mu sync.Mutex
786
792
 
787
793
  claimed bool
794
+ renderText func(task.TextKind, task.TextData) (string, error)
788
795
  mailboxes map[string]task.Mailbox
789
796
  specs map[string]task.MailboxSpec
790
797
  mailboxStatus map[string]string
@@ -833,6 +840,22 @@ func (s *scenarioTaskSystem) ValidateConfig(context.Context, config.RawValues, m
833
840
  return nil
834
841
  }
835
842
 
843
+ func (s *scenarioTaskSystem) RenderText(kind task.TextKind, data task.TextData) (string, error) {
844
+ if s.renderText != nil {
845
+ return s.renderText(kind, data)
846
+ }
847
+ switch kind {
848
+ case task.TextMailboxDescription:
849
+ return "mailbox " + data.Node + ": " + data.NodeDescription, nil
850
+ case task.TextSummaryComment:
851
+ return "SUMMARY\n" + data.SummaryReport, nil
852
+ case task.TextFeedbackComment:
853
+ return "Feedback from " + data.SourceNode + " to " + data.TargetNode + " mailbox " + data.Mailbox + "\n" + data.FeedbackReport, nil
854
+ default:
855
+ return "", fmt.Errorf("unknown task text kind %q", kind)
856
+ }
857
+ }
858
+
836
859
  func (s *scenarioTaskSystem) EnsureMailboxes(_ context.Context, parent task.TicketRef, _ string, specs []task.MailboxSpec) (map[string]task.Mailbox, error) {
837
860
  s.log.add("mailboxes:ensure")
838
861
  s.mu.Lock()
@@ -1121,6 +1144,8 @@ type scenarioHarness struct {
1121
1144
  launches map[string][]harness.LaunchSpec
1122
1145
  }
1123
1146
 
1147
+ func (*scenarioHarness) SetupRepo(context.Context, string) error { return nil }
1148
+
1124
1149
  var _ harness.Harness = (*scenarioHarness)(nil)
1125
1150
 
1126
1151
  func newScenarioHarness(log *scenarioLog) *scenarioHarness {
@@ -1139,6 +1164,17 @@ func (h *scenarioHarness) FindSession(_ context.Context, _ string, title string)
1139
1164
  return session, ok, nil
1140
1165
  }
1141
1166
 
1167
+ func (h *scenarioHarness) RenderPrompt(kind harness.PromptKind, data harness.PromptData, nudge string) (string, error) {
1168
+ prompt := string(kind) + " taskSystem=" + data.TaskSystem + " mailbox=" + data.Mailbox
1169
+ if data.NodeType == workflow.NodeHITL {
1170
+ prompt += " hitl"
1171
+ }
1172
+ if nudge != "" {
1173
+ prompt += " " + nudge
1174
+ }
1175
+ return prompt, nil
1176
+ }
1177
+
1142
1178
  func (h *scenarioHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
1143
1179
  h.mu.Lock()
1144
1180
  h.launches[spec.Node] = append(h.launches[spec.Node], spec)
@@ -35,6 +35,7 @@ import (
35
35
  // Adapter registrations (factories are registered by name via init).
36
36
  _ "github.com/rajpopat27/relay-flow/internal/harness/opencode"
37
37
  _ "github.com/rajpopat27/relay-flow/internal/runner/orca"
38
+ _ "github.com/rajpopat27/relay-flow/internal/task/beads"
38
39
  _ "github.com/rajpopat27/relay-flow/internal/task/jira"
39
40
  )
40
41
 
@@ -263,6 +264,7 @@ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
263
264
  ConfigPath: p.Config,
264
265
  TaskPlugin: cfg.TaskPlugin,
265
266
  Runner: rnr,
267
+ Harness: hrn,
266
268
  Active: engine,
267
269
  Workflows: wfSvc.Registry(),
268
270
  }, repoReg)
@@ -273,8 +275,8 @@ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
273
275
  // specs come from the engine so the recover path builds the same
274
276
  // description content as normal run execution.
275
277
  if recover {
276
- specsFor := func(wf *workflow.Workflow, ticketKey string) []task.MailboxSpec {
277
- return goworkflows.MailboxSpecs(wf, ticketKey)
278
+ specsFor := func(sys task.System, work runsvc.Work, wf *workflow.Workflow) ([]task.MailboxSpec, error) {
279
+ return goworkflows.RenderMailboxSpecs(sys, work, wf)
278
280
  }
279
281
  if err := recoverpkg.FromTaskSystem(ctx, repoReg, rnr, runManager, specsFor); err != nil {
280
282
  shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -0,0 +1,74 @@
1
+ # Beads workflow example. Register the code repository with a required
2
+ # repo-scoped taskConfig.beadsDir before submitting this workflow, for example:
3
+ #
4
+ # relay-flow repo register --name payments --path /work/payments \
5
+ # --set beadsDir=/work/payments/.beads
6
+ #
7
+ # The Beads prefix is optional and is not a workspace/component selector.
8
+ name: beadsStoryFlow
9
+ repos:
10
+ - payments
11
+ cleanupRunnerOnEnd: true
12
+
13
+ taskConfig:
14
+ filters:
15
+ # Beads statuses are open, in_progress, blocked, deferred, hooked, closed.
16
+ parentStatuses:
17
+ - open
18
+ issueTypes:
19
+ - epic
20
+ labels:
21
+ - relay-ready
22
+ # assignees:
23
+ # - owner@example.com
24
+ # Optional shared default; filters.assignees overrides it when provided.
25
+ # An assignee in effect for a node also assigns that node's mailbox.
26
+ # assignee: owner@example.com
27
+
28
+ # transitionTo describes one lifecycle point, so configure it on a node.
29
+ # Omitting it entirely already gives the Jira-equivalent lifecycle:
30
+ # start parent -> in_progress, work mailbox -> in_progress, end parent -> closed.
31
+ # Values are Beads-native, not Jira values such as "In Progress" or "Done".
32
+ nodes:
33
+ start:
34
+ taskConfig:
35
+ transitionTo:
36
+ parentStatus: in_progress
37
+ onSuccess:
38
+ - target: implement
39
+ when: The Beads parent is ready for implementation
40
+
41
+ implement:
42
+ type: agent
43
+ agent: build
44
+ description: Implement the Beads parent and verify the changes.
45
+ taskConfig:
46
+ # assignee: developer@example.com
47
+ transitionTo:
48
+ taskStatus: in_progress
49
+ onSuccess:
50
+ - target: review
51
+ when: Implementation and verification are complete
52
+ onFailure:
53
+ - target: implement
54
+ when: Implementation needs another pass
55
+
56
+ review:
57
+ type: hitl
58
+ agent: plan
59
+ description: Review the implementation and obtain approval.
60
+ taskConfig:
61
+ # assignee: reviewer@example.com
62
+ transitionTo:
63
+ taskStatus: in_progress
64
+ onSuccess:
65
+ - target: end
66
+ when: The review is approved
67
+ onFailure:
68
+ - target: implement
69
+ when: Changes are requested
70
+
71
+ end:
72
+ taskConfig:
73
+ transitionTo:
74
+ parentStatus: closed
@@ -2,7 +2,7 @@
2
2
  # Optional fields are shown commented; uncomment only the behavior you need.
3
3
  name: defaultStoryFlow
4
4
  repos:
5
- - payments
5
+ - test-relay-flow
6
6
  cleanupRunnerOnEnd: true
7
7
 
8
8
  taskConfig:
@@ -33,7 +33,7 @@ nodes:
33
33
 
34
34
  implement:
35
35
  type: agent
36
- agent: coding
36
+ agent: build
37
37
  description: Implement the parent story and verify the changes.
38
38
  # nudgePrompt: "Finish {{node}} for {{ticket}} and choose one of: {{nextSteps}}"
39
39
  taskConfig:
@@ -50,13 +50,13 @@ nodes:
50
50
 
51
51
  review:
52
52
  type: agent
53
- agent: review
53
+ agent: plan
54
54
  description: Review the implementation for correctness, regressions, and missing tests.
55
55
  # nudgePrompt: "Complete the review for {{ticket}}. Valid routes: {{nextSteps}}"
56
56
  taskConfig:
57
57
  # assignee: reviewer@example.com
58
58
  transitionTo:
59
- taskStatus: In Review
59
+ taskStatus: In Progress
60
60
  # parentStatus: In Review
61
61
  onSuccess:
62
62
  - target: prReview
@@ -67,13 +67,13 @@ nodes:
67
67
 
68
68
  prReview:
69
69
  type: hitl
70
- agent: review
70
+ agent: plan
71
71
  description: Discuss the pull request with the human and obtain approval.
72
72
  # nudgePrompt: "Review {{ticket}} with the human. Valid routes: {{nextSteps}}"
73
73
  taskConfig:
74
74
  # assignee: human-reviewer@example.com
75
75
  transitionTo:
76
- taskStatus: In Review
76
+ taskStatus: In Progress
77
77
  # parentStatus: In Review
78
78
  onSuccess:
79
79
  - target: end
package/go.mod CHANGED
@@ -2,10 +2,11 @@ module github.com/rajpopat27/relay-flow
2
2
 
3
3
  go 1.24.6
4
4
 
5
- require (
5
+ require (
6
6
  github.com/cschleiden/go-workflows v1.4.2
7
7
  github.com/google/renameio/v2 v2.0.1
8
8
  github.com/google/uuid v1.6.0
9
+ github.com/yuin/goldmark v1.7.13
9
10
  gopkg.in/yaml.v3 v3.0.1
10
11
  )
11
12
 
package/go.sum CHANGED
@@ -122,6 +122,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
122
122
  github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
123
123
  github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
124
124
  github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
125
+ github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
126
+ github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
125
127
  go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY=
126
128
  go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
127
129
  go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
@@ -26,9 +26,9 @@ func Merge(values ...RawValues) RawValues {
26
26
 
27
27
  func mergeInto(dst, src map[string]any) map[string]any {
28
28
  for k, sv := range src {
29
- if sm, ok := sv.(map[string]any); ok {
29
+ if sm, ok := rawMap(sv); ok {
30
30
  fresh := map[string]any{}
31
- if dm, ok := dst[k].(map[string]any); ok {
31
+ if dm, ok := rawMap(dst[k]); ok {
32
32
  mergeInto(fresh, dm)
33
33
  }
34
34
  dst[k] = mergeInto(fresh, sm)
@@ -39,6 +39,17 @@ func mergeInto(dst, src map[string]any) map[string]any {
39
39
  return dst
40
40
  }
41
41
 
42
+ func rawMap(value any) (map[string]any, bool) {
43
+ switch values := value.(type) {
44
+ case map[string]any:
45
+ return values, true
46
+ case RawValues:
47
+ return map[string]any(values), true
48
+ default:
49
+ return nil, false
50
+ }
51
+ }
52
+
42
53
  // DecodeStrict decodes raw values into dst, rejecting unknown fields and
43
54
  // explicit null values.
44
55
  func DecodeStrict(values RawValues, dst any) error {
@@ -49,6 +49,24 @@ func TestMergeMapsRecursively(t *testing.T) {
49
49
  }
50
50
  }
51
51
 
52
+ func TestMergeRawValuesMapsRecursively(t *testing.T) {
53
+ defaults := config.RawValues{"templates": map[string]any{
54
+ "mailboxDescription": "default mailbox",
55
+ "summaryComment": "default summary",
56
+ }}
57
+ override := config.RawValues{"templates": config.RawValues{
58
+ "mailboxDescription": "custom mailbox",
59
+ }}
60
+ got := config.Merge(defaults, override)
61
+ templates, ok := got["templates"].(map[string]any)
62
+ if !ok {
63
+ t.Fatalf("templates = %#v", got["templates"])
64
+ }
65
+ if templates["mailboxDescription"] != "custom mailbox" || templates["summaryComment"] != "default summary" {
66
+ t.Fatalf("templates = %#v", templates)
67
+ }
68
+ }
69
+
52
70
  func TestMergeListReplaces(t *testing.T) {
53
71
  root := config.RawValues{"labels": []any{"a", "b"}}
54
72
  wf := config.RawValues{"labels": []any{"c"}}
@@ -51,6 +51,16 @@ func (a *Activities) EnsureMailboxes(ctx context.Context, w run.Work, specs []ta
51
51
  if err != nil {
52
52
  return nil, err
53
53
  }
54
+ for i := range specs {
55
+ data := specs[i].TextData
56
+ data.RunID = string(w.RunID)
57
+ data.Repo = w.Repo
58
+ custom, err := sys.RenderText(task.TextMailboxDescription, data)
59
+ if err != nil {
60
+ return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
61
+ }
62
+ specs[i].Description = appendText(custom, specs[i].Description)
63
+ }
54
64
  return sys.EnsureMailboxes(ctx, w.Parent, w.Workflow, specs)
55
65
  }
56
66
 
@@ -147,60 +157,65 @@ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, rep
147
157
  if err := a.Runner.SetEnvironmentStatus(ctx, env, status); err != nil {
148
158
  return err
149
159
  }
150
- hadRuntime := currentRuntime.TerminalID != "" || currentRuntime.SessionID != ""
151
160
  // IDs come from the guarded current row; the activity input's prior visit
152
161
  // is used only to decide whether a live process needs rebinding.
153
162
  rt.TerminalID = currentRuntime.TerminalID
154
163
  rt.SessionID = currentRuntime.SessionID
155
164
  spec.ResumeID = rt.SessionID
156
- // Custom instructions belong to a node entry, not same-visit recovery.
157
- if !hadRuntime || revisit {
158
- spec.Prompt = appendPrompt(spec.Prompt, spec.NudgePrompt)
159
- }
160
- cmd, err := a.Harness.BuildCommand(spec)
161
- if err != nil {
162
- return err
163
- }
164
165
  stored := runner.Terminal{ID: rt.TerminalID, Title: spec.Title}
165
- terminal, err := a.Runner.EnsureTerminal(ctx, env, stored, spec.Title, cmd)
166
+ terminal, live, err := a.Runner.FindTerminal(ctx, stored)
166
167
  if err != nil {
167
168
  return err
168
169
  }
169
- // Persist a newly created/replacement handle before any later external
170
- // effect. Runtime session registration may update SessionID independently.
171
- if err := a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
172
- terminal.ID, rt.SessionID, rt.SessionID); err != nil {
173
- // The visit changed after terminal creation. Close the terminal whose
174
- // binding was rejected so stale work cannot leak or report.
175
- if terminal.ID != rt.TerminalID {
176
- _ = a.Runner.CloseTerminal(ctx, terminal)
170
+ if live {
171
+ if err := a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
172
+ terminal.ID, rt.SessionID, rt.SessionID); err != nil {
173
+ return err
174
+ }
175
+ if !revisit {
176
+ // Same-visit retry/restart is silent: do not render, build, or send.
177
+ return nil
178
+ }
179
+ prompt, err := a.Harness.RenderPrompt(harness.PromptFeedback, spec.PromptData, spec.NudgePrompt)
180
+ if err != nil {
181
+ return err
182
+ }
183
+ if err := a.Runner.SendTerminal(ctx, terminal, prompt); err == nil {
184
+ return nil
185
+ }
186
+ // Direct use failed. Close the known live terminal before replacing it
187
+ // so a second agent process cannot be left running.
188
+ if err := a.Runner.CloseTerminal(ctx, terminal); err != nil {
189
+ return err
177
190
  }
178
- return err
179
- }
180
- if terminal.ID != rt.TerminalID || !revisit {
181
- // A newly created terminal receives its prompt in the launch command;
182
- // same-visit reuse leaves the running turn untouched.
183
- return nil
184
191
  }
185
- prompt := followUpPrompt(nw.Mailbox.Key)
186
- if spec.NudgePrompt != "" {
187
- prompt += "\n\n" + spec.NudgePrompt
192
+
193
+ // An initial or replacement terminal resumes the stored session and
194
+ // receives the rendered initial prompt. Same-visit replacements omit the
195
+ // node nudge; a new visit includes it.
196
+ nudge := ""
197
+ if rt.NodeVisitID == "" || revisit {
198
+ nudge = spec.NudgePrompt
188
199
  }
189
- if err := a.Runner.SendTerminal(ctx, terminal, prompt); err == nil {
190
- return nil
200
+ spec.Prompt, err = a.Harness.RenderPrompt(harness.PromptInitial, spec.PromptData, nudge)
201
+ if err != nil {
202
+ return err
191
203
  }
192
- // Direct use failed. Close the known live terminal before replacing it so
193
- // a second agent process cannot be left running.
194
- if err := a.Runner.CloseTerminal(ctx, terminal); err != nil {
204
+ cmd, err := a.Harness.BuildCommand(spec)
205
+ if err != nil {
195
206
  return err
196
207
  }
197
- replacement, err := a.Runner.EnsureTerminal(ctx, env, terminal, spec.Title, cmd)
208
+ replacement, err := a.Runner.EnsureTerminal(ctx, env, stored, spec.Title, cmd)
198
209
  if err != nil {
199
210
  return err
200
211
  }
212
+ // Persist a newly created/replacement handle before any later external
213
+ // effect. Runtime session registration may update SessionID independently.
201
214
  if err := a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
202
215
  replacement.ID, rt.SessionID, rt.SessionID); err != nil {
203
- _ = a.Runner.CloseTerminal(ctx, replacement)
216
+ if replacement.ID != rt.TerminalID {
217
+ _ = a.Runner.CloseTerminal(ctx, replacement)
218
+ }
204
219
  return err
205
220
  }
206
221
  return nil
@@ -276,7 +291,14 @@ func (a *Activities) Comment(ctx context.Context, repoName string, cw run.Commen
276
291
  if err != nil {
277
292
  return err
278
293
  }
279
- if err := sys.Comment(ctx, cw.Item, cw.Body, cw.Marker); err != nil {
294
+ body := cw.Body
295
+ if cw.TextKind != "" {
296
+ body, err = sys.RenderText(cw.TextKind, cw.TextData)
297
+ if err != nil {
298
+ return fmt.Errorf("render %s: %w", cw.TextKind, err)
299
+ }
300
+ }
301
+ if err := sys.Comment(ctx, cw.Item, body, cw.Marker); err != nil {
280
302
  return err
281
303
  }
282
304
  // Log AFTER the write succeeds so the line is a true effect record.
@@ -395,17 +417,7 @@ func (a *Activities) ProjectionUpdateRetry(ctx context.Context, id run.ID, statu
395
417
  // description, and every legal route with its when explanation.
396
418
  func MailboxSpecForNode(wf *workflow.Workflow, ticketKey, name string, n workflow.Node) task.MailboxSpec {
397
419
  var b strings.Builder
398
- fmt.Fprintf(&b, "Parent ticket: %s\nNode: %s\nType: %s\nAgent: %s\n\nWork:\n%s\n\nRead this subtask's comments for feedback from previous nodes.",
399
- ticketKey, name, n.Type, n.Agent, n.Description)
400
- if n.Type == workflow.NodeHITL {
401
- b.WriteString(`
402
-
403
- 1. Discuss the task with the human, request the PR link or any missing context, and review the changes. Do not make code changes.
404
- 2. Resolve questions and requested review updates through normal conversation until the human is satisfied with the review.`)
405
- }
406
- b.WriteString(`
407
-
408
- Required report format:
420
+ b.WriteString(`Required report format:
409
421
 
410
422
  STATUS: success | failure
411
423
  NEXT STEP: <one valid node name>
@@ -424,7 +436,9 @@ REQUIRED ACTIONS:
424
436
  RELEVANT CONTEXT:
425
437
  EXPECTED RESULT:
426
438
 
427
- Every field is required; use None for an intentionally empty section. COMMITS must contain the relevant commit IDs or None. NEXT STEP must name exactly one target listed below for your status. When NEXT STEP is end, every FEEDBACK field must be None.`)
439
+ Every field is required; use None for an intentionally empty section. COMMITS must contain the relevant commit IDs or None.
440
+
441
+ 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.`)
428
442
  writeRoutes := func(label string, routes []workflow.Route) {
429
443
  if len(routes) == 0 {
430
444
  return
@@ -440,11 +454,20 @@ Every field is required; use None for an intentionally empty section. COMMITS mu
440
454
  }
441
455
  writeRoutes("On success", n.OnSuccess)
442
456
  writeRoutes("On failure", n.OnFailure)
457
+ successRoutes := routesText(n.OnSuccess)
458
+ failureRoutes := routesText(n.OnFailure)
443
459
  return task.MailboxSpec{
444
460
  Node: name,
445
461
  Title: ticketKey + ":" + name,
446
462
  Description: b.String(),
447
463
  TaskConfig: n.TaskConfig,
464
+ TextData: task.TextData{
465
+ Ticket: ticketKey, Workflow: wf.Name, Node: name, NodeType: string(n.Type),
466
+ Agent: n.Agent, NodeDescription: n.Description,
467
+ NextSteps: nextStepsText(append(append([]workflow.Route{}, n.OnSuccess...), n.OnFailure...)),
468
+ SuccessRoutes: successRoutes, FailureRoutes: failureRoutes,
469
+ Mailbox: ticketKey + ":" + name,
470
+ },
448
471
  }
449
472
  }
450
473
 
@@ -464,20 +487,46 @@ func MailboxSpecs(wf *workflow.Workflow, ticketKey string) []task.MailboxSpec {
464
487
  return out
465
488
  }
466
489
 
467
- // BuildLaunchSpecPrompt points the agent to its parent and isolated mailbox.
468
- func BuildLaunchSpecPrompt(taskSystem, ticketKey, mailboxKey string) string {
469
- return fmt.Sprintf("Task system: %s\nUse the %s tools to read the parent ticket %s.\n\nYour mailbox is %s. Read its description and comments for node instructions and feedback.", taskSystem, taskSystem, ticketKey, mailboxKey)
490
+ // RenderMailboxSpecs asks the selected task system to render customizable
491
+ // mailbox text, then appends the fixed report contract and legal routes.
492
+ // It is shared by normal execution and explicit database-loss recovery.
493
+ func RenderMailboxSpecs(sys task.System, w run.Work, wf *workflow.Workflow) ([]task.MailboxSpec, error) {
494
+ specs := MailboxSpecs(wf, w.Parent.Key)
495
+ for i := range specs {
496
+ data := specs[i].TextData
497
+ data.RunID = string(w.RunID)
498
+ data.Repo = w.Repo
499
+ custom, err := sys.RenderText(task.TextMailboxDescription, data)
500
+ if err != nil {
501
+ return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
502
+ }
503
+ specs[i].Description = appendText(custom, specs[i].Description)
504
+ }
505
+ return specs, nil
470
506
  }
471
507
 
472
- func followUpPrompt(mailboxKey string) string {
473
- return fmt.Sprintf("New feedback was added to the comments section of your mailbox subtask %s. Read it.", mailboxKey)
508
+ func routesText(routes []workflow.Route) string {
509
+ var b strings.Builder
510
+ for i, route := range routes {
511
+ if i > 0 {
512
+ b.WriteString("\n")
513
+ }
514
+ b.WriteString(route.Target)
515
+ if route.When != "" {
516
+ b.WriteString(" — when: " + route.When)
517
+ }
518
+ }
519
+ return b.String()
474
520
  }
475
521
 
476
- func appendPrompt(prompt, extra string) string {
477
- if extra == "" {
478
- return prompt
522
+ func appendText(first, second string) string {
523
+ if first == "" {
524
+ return second
525
+ }
526
+ if second == "" {
527
+ return first
479
528
  }
480
- return prompt + "\n\n" + extra
529
+ return first + "\n\n" + second
481
530
  }
482
531
 
483
532
  // mergeTaskConfig overlays node task config onto workflow task config using