relay-flow 0.2.1-alpha → 0.2.3-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 (83) hide show
  1. package/README.md +193 -25
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +79 -1
  4. package/cmd/relay-flow/main.go +50 -1
  5. package/cmd/relay-flow/scenario_test.go +48 -12
  6. package/cmd/relay-flow/serve.go +16 -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 +124 -56
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine.go +20 -0
  16. package/internal/execution/goworkflows/engine_test.go +33 -15
  17. package/internal/execution/goworkflows/fakes_test.go +84 -10
  18. package/internal/execution/goworkflows/interpreter.go +84 -30
  19. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  20. package/internal/execution/goworkflows/node_runtime_test.go +30 -5
  21. package/internal/execution/goworkflows/projection.go +52 -11
  22. package/internal/execution/goworkflows/recovery_test.go +131 -2
  23. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  24. package/internal/harness/contract_test.go +10 -0
  25. package/internal/harness/factory.go +25 -3
  26. package/internal/harness/harness.go +30 -4
  27. package/internal/harness/opencode/opencode.go +125 -10
  28. package/internal/harness/opencode/opencode_test.go +238 -0
  29. package/internal/harness/opencode/repo_setup.go +401 -0
  30. package/internal/harness/plugin_selection_test.go +5 -5
  31. package/internal/identity/identity.go +28 -1
  32. package/internal/identity/identity_test.go +40 -0
  33. package/internal/recover/recover.go +11 -6
  34. package/internal/repo/service.go +10 -0
  35. package/internal/repo/service_test.go +51 -2
  36. package/internal/run/manager.go +193 -25
  37. package/internal/run/run.go +30 -10
  38. package/internal/run/run_manager_test.go +100 -0
  39. package/internal/server/api_test.go +54 -0
  40. package/internal/server/client.go +11 -0
  41. package/internal/server/fixture_test.go +18 -0
  42. package/internal/server/server.go +16 -0
  43. package/internal/task/beads/bdcli/bdcli.go +323 -0
  44. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  45. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  46. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  47. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  48. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  49. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  50. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  51. package/internal/task/beads/bdcli/testdata/empty.json +1 -0
  52. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  53. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  54. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  55. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  56. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  57. package/internal/task/beads/beads.go +856 -0
  58. package/internal/task/beads/beads_test.go +609 -0
  59. package/internal/task/beads/comments_test.go +242 -0
  60. package/internal/task/beads/config_compatibility_test.go +163 -0
  61. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  62. package/internal/task/beads/repo_composition_test.go +232 -0
  63. package/internal/task/beads/runtime_config_test.go +81 -0
  64. package/internal/task/beads/status_compatibility_test.go +276 -0
  65. package/internal/task/beads/status_test.go +257 -0
  66. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  67. package/internal/task/beads/validation_test.go +110 -0
  68. package/internal/task/contract_test.go +10 -0
  69. package/internal/task/factory.go +37 -4
  70. package/internal/task/jira/auth.go +27 -1
  71. package/internal/task/jira/auth_test.go +54 -1
  72. package/internal/task/jira/filters_test.go +60 -0
  73. package/internal/task/jira/helpers_test.go +3 -0
  74. package/internal/task/jira/jira.go +187 -29
  75. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  76. package/internal/task/jira/rest/adf.go +119 -82
  77. package/internal/task/jira/rest/adf_test.go +60 -0
  78. package/internal/task/jira/rest/client_test.go +1 -1
  79. package/internal/task/jira/templates_test.go +118 -0
  80. package/internal/task/jira/transition_defaults_test.go +47 -2
  81. package/internal/task/task.go +40 -0
  82. package/internal/workflow/report_test.go +45 -0
  83. 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
 
@@ -248,6 +249,8 @@ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
248
249
  // the SAME registry by pointer (via replaceInternal), so the engine,
249
250
  // pollers, and handlers observe one in-memory repo set.
250
251
  wfSvc := workflow.NewService(store, engine, repoExists{repoReg})
252
+ runManager.Repos = repoReg
253
+ runManager.Workflows = wfSvc.Registry()
251
254
  wfSvc.Gate = lifecycleGate
252
255
  wfSvc.ValidateTaskConfig = workflowConfigValidator(repoReg)
253
256
  // Submit/Remove must also rebuild repo bindings under the gate (spec
@@ -263,6 +266,7 @@ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
263
266
  ConfigPath: p.Config,
264
267
  TaskPlugin: cfg.TaskPlugin,
265
268
  Runner: rnr,
269
+ Harness: hrn,
266
270
  Active: engine,
267
271
  Workflows: wfSvc.Registry(),
268
272
  }, repoReg)
@@ -273,8 +277,8 @@ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
273
277
  // specs come from the engine so the recover path builds the same
274
278
  // description content as normal run execution.
275
279
  if recover {
276
- specsFor := func(wf *workflow.Workflow, ticketKey string) []task.MailboxSpec {
277
- return goworkflows.MailboxSpecs(wf, ticketKey)
280
+ specsFor := func(sys task.System, work runsvc.Work, wf *workflow.Workflow) ([]task.MailboxSpec, error) {
281
+ return goworkflows.RenderMailboxSpecs(sys, work, wf)
278
282
  }
279
283
  if err := recoverpkg.FromTaskSystem(ctx, repoReg, rnr, runManager, specsFor); err != nil {
280
284
  shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -453,6 +457,16 @@ func (d *serveDeps) ListRuns(ctx context.Context, filter runsvc.Filter) ([]runsv
453
457
  func (d *serveDeps) GetRunByTicket(ctx context.Context, ticket string) (runsvc.Run, error) {
454
458
  return d.engine.FindRunByTicket(ctx, ticket)
455
459
  }
460
+ func (d *serveDeps) RestartRun(ctx context.Context, ticket string) (runsvc.Run, error) {
461
+ rn, err := d.runManager.RestartByTicket(ctx, ticket)
462
+ if err != nil {
463
+ if errors.Is(err, runsvc.ErrRestartConflict) {
464
+ return runsvc.Run{}, fmt.Errorf("%w: %v", server.ErrConflict, err)
465
+ }
466
+ return runsvc.Run{}, err
467
+ }
468
+ return rn, nil
469
+ }
456
470
  func (d *serveDeps) CancelRun(ctx context.Context, ticket, reason string) error {
457
471
  return d.runManager.CancelByTicket(ctx, ticket, reason)
458
472
  }
@@ -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,9 +51,38 @@ 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
 
67
+ // PrepareRestart reopens mailbox state through the optional task-system
68
+ // capability, then closes any surviving run-owned terminals while preserving
69
+ // the ticket worktree. Both operations are idempotent/retryable and remain
70
+ // behind their respective task and runner interfaces.
71
+ func (a *Activities) PrepareRestart(ctx context.Context, w run.Work, repoPath string, mailboxes []task.Mailbox) error {
72
+ sys, err := a.taskSystem(w.Repo)
73
+ if err != nil {
74
+ return err
75
+ }
76
+ if preparer, ok := sys.(task.RestartPreparer); ok {
77
+ if err := preparer.PrepareRestart(ctx, w.Parent, mailboxes); err != nil {
78
+ return err
79
+ }
80
+ }
81
+ spec := a.runSpec(w)
82
+ spec.RepoPath = repoPath
83
+ return a.Runner.CloseTerminals(ctx, spec)
84
+ }
85
+
57
86
  // ValidateAgents validates every referenced agent on the repo.
58
87
  func (a *Activities) ValidateAgents(ctx context.Context, repoPath string, agents []string) error {
59
88
  for _, agent := range agents {
@@ -147,60 +176,65 @@ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, rep
147
176
  if err := a.Runner.SetEnvironmentStatus(ctx, env, status); err != nil {
148
177
  return err
149
178
  }
150
- hadRuntime := currentRuntime.TerminalID != "" || currentRuntime.SessionID != ""
151
179
  // IDs come from the guarded current row; the activity input's prior visit
152
180
  // is used only to decide whether a live process needs rebinding.
153
181
  rt.TerminalID = currentRuntime.TerminalID
154
182
  rt.SessionID = currentRuntime.SessionID
155
183
  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
184
  stored := runner.Terminal{ID: rt.TerminalID, Title: spec.Title}
165
- terminal, err := a.Runner.EnsureTerminal(ctx, env, stored, spec.Title, cmd)
185
+ terminal, live, err := a.Runner.FindTerminal(ctx, stored)
166
186
  if err != nil {
167
187
  return err
168
188
  }
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)
189
+ if live {
190
+ if err := a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
191
+ terminal.ID, rt.SessionID, rt.SessionID); err != nil {
192
+ return err
193
+ }
194
+ if !revisit {
195
+ // Same-visit retry/restart is silent: do not render, build, or send.
196
+ return nil
197
+ }
198
+ prompt, err := a.Harness.RenderPrompt(harness.PromptFeedback, spec.PromptData, spec.NudgePrompt)
199
+ if err != nil {
200
+ return err
201
+ }
202
+ if err := a.Runner.SendTerminal(ctx, terminal, prompt); err == nil {
203
+ return nil
204
+ }
205
+ // Direct use failed. Close the known live terminal before replacing it
206
+ // so a second agent process cannot be left running.
207
+ if err := a.Runner.CloseTerminal(ctx, terminal); err != nil {
208
+ return err
177
209
  }
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
210
  }
185
- prompt := followUpPrompt(nw.Mailbox.Key)
186
- if spec.NudgePrompt != "" {
187
- prompt += "\n\n" + spec.NudgePrompt
211
+
212
+ // An initial or replacement terminal resumes the stored session and
213
+ // receives the rendered initial prompt. Same-visit replacements omit the
214
+ // node nudge; a new visit includes it.
215
+ nudge := ""
216
+ if rt.NodeVisitID == "" || revisit {
217
+ nudge = spec.NudgePrompt
188
218
  }
189
- if err := a.Runner.SendTerminal(ctx, terminal, prompt); err == nil {
190
- return nil
219
+ spec.Prompt, err = a.Harness.RenderPrompt(harness.PromptInitial, spec.PromptData, nudge)
220
+ if err != nil {
221
+ return err
191
222
  }
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 {
223
+ cmd, err := a.Harness.BuildCommand(spec)
224
+ if err != nil {
195
225
  return err
196
226
  }
197
- replacement, err := a.Runner.EnsureTerminal(ctx, env, terminal, spec.Title, cmd)
227
+ replacement, err := a.Runner.EnsureTerminal(ctx, env, stored, spec.Title, cmd)
198
228
  if err != nil {
199
229
  return err
200
230
  }
231
+ // Persist a newly created/replacement handle before any later external
232
+ // effect. Runtime session registration may update SessionID independently.
201
233
  if err := a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
202
234
  replacement.ID, rt.SessionID, rt.SessionID); err != nil {
203
- _ = a.Runner.CloseTerminal(ctx, replacement)
235
+ if replacement.ID != rt.TerminalID {
236
+ _ = a.Runner.CloseTerminal(ctx, replacement)
237
+ }
204
238
  return err
205
239
  }
206
240
  return nil
@@ -276,7 +310,14 @@ func (a *Activities) Comment(ctx context.Context, repoName string, cw run.Commen
276
310
  if err != nil {
277
311
  return err
278
312
  }
279
- if err := sys.Comment(ctx, cw.Item, cw.Body, cw.Marker); err != nil {
313
+ body := cw.Body
314
+ if cw.TextKind != "" {
315
+ body, err = sys.RenderText(cw.TextKind, cw.TextData)
316
+ if err != nil {
317
+ return fmt.Errorf("render %s: %w", cw.TextKind, err)
318
+ }
319
+ }
320
+ if err := sys.Comment(ctx, cw.Item, body, cw.Marker); err != nil {
280
321
  return err
281
322
  }
282
323
  // Log AFTER the write succeeds so the line is a true effect record.
@@ -395,17 +436,7 @@ func (a *Activities) ProjectionUpdateRetry(ctx context.Context, id run.ID, statu
395
436
  // description, and every legal route with its when explanation.
396
437
  func MailboxSpecForNode(wf *workflow.Workflow, ticketKey, name string, n workflow.Node) task.MailboxSpec {
397
438
  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:
439
+ b.WriteString(`Required report format:
409
440
 
410
441
  STATUS: success | failure
411
442
  NEXT STEP: <one valid node name>
@@ -424,7 +455,9 @@ REQUIRED ACTIONS:
424
455
  RELEVANT CONTEXT:
425
456
  EXPECTED RESULT:
426
457
 
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.`)
458
+ Every field is required; use None for an intentionally empty section. COMMITS must contain the relevant commit IDs or None.
459
+
460
+ 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
461
  writeRoutes := func(label string, routes []workflow.Route) {
429
462
  if len(routes) == 0 {
430
463
  return
@@ -440,11 +473,20 @@ Every field is required; use None for an intentionally empty section. COMMITS mu
440
473
  }
441
474
  writeRoutes("On success", n.OnSuccess)
442
475
  writeRoutes("On failure", n.OnFailure)
476
+ successRoutes := routesText(n.OnSuccess)
477
+ failureRoutes := routesText(n.OnFailure)
443
478
  return task.MailboxSpec{
444
479
  Node: name,
445
480
  Title: ticketKey + ":" + name,
446
481
  Description: b.String(),
447
482
  TaskConfig: n.TaskConfig,
483
+ TextData: task.TextData{
484
+ Ticket: ticketKey, Workflow: wf.Name, Node: name, NodeType: string(n.Type),
485
+ Agent: n.Agent, NodeDescription: n.Description,
486
+ NextSteps: nextStepsText(append(append([]workflow.Route{}, n.OnSuccess...), n.OnFailure...)),
487
+ SuccessRoutes: successRoutes, FailureRoutes: failureRoutes,
488
+ Mailbox: ticketKey + ":" + name,
489
+ },
448
490
  }
449
491
  }
450
492
 
@@ -464,20 +506,46 @@ func MailboxSpecs(wf *workflow.Workflow, ticketKey string) []task.MailboxSpec {
464
506
  return out
465
507
  }
466
508
 
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)
509
+ // RenderMailboxSpecs asks the selected task system to render customizable
510
+ // mailbox text, then appends the fixed report contract and legal routes.
511
+ // It is shared by normal execution and explicit database-loss recovery.
512
+ func RenderMailboxSpecs(sys task.System, w run.Work, wf *workflow.Workflow) ([]task.MailboxSpec, error) {
513
+ specs := MailboxSpecs(wf, w.Parent.Key)
514
+ for i := range specs {
515
+ data := specs[i].TextData
516
+ data.RunID = string(w.RunID)
517
+ data.Repo = w.Repo
518
+ custom, err := sys.RenderText(task.TextMailboxDescription, data)
519
+ if err != nil {
520
+ return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
521
+ }
522
+ specs[i].Description = appendText(custom, specs[i].Description)
523
+ }
524
+ return specs, nil
470
525
  }
471
526
 
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)
527
+ func routesText(routes []workflow.Route) string {
528
+ var b strings.Builder
529
+ for i, route := range routes {
530
+ if i > 0 {
531
+ b.WriteString("\n")
532
+ }
533
+ b.WriteString(route.Target)
534
+ if route.When != "" {
535
+ b.WriteString(" — when: " + route.When)
536
+ }
537
+ }
538
+ return b.String()
474
539
  }
475
540
 
476
- func appendPrompt(prompt, extra string) string {
477
- if extra == "" {
478
- return prompt
541
+ func appendText(first, second string) string {
542
+ if first == "" {
543
+ return second
544
+ }
545
+ if second == "" {
546
+ return first
479
547
  }
480
- return prompt + "\n\n" + extra
548
+ return first + "\n\n" + second
481
549
  }
482
550
 
483
551
  // mergeTaskConfig overlays node task config onto workflow task config using