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
@@ -0,0 +1,124 @@
1
+ package goworkflows_test
2
+
3
+ import (
4
+ "context"
5
+ "strings"
6
+ "testing"
7
+ "time"
8
+
9
+ "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
10
+ "github.com/rajpopat27/relay-flow/internal/identity"
11
+ "github.com/rajpopat27/relay-flow/internal/run"
12
+ "github.com/rajpopat27/relay-flow/internal/task"
13
+ "github.com/rajpopat27/relay-flow/internal/workflow"
14
+ )
15
+
16
+ func TestEndSelectionSkipsFeedbackCommentActivity(t *testing.T) {
17
+ log := newEventLog()
18
+ sys := newFakeTaskSystem(log)
19
+ engine := newEndFeedbackTestEngine(t, sys)
20
+ wf := workflow.Workflow{
21
+ Name: "endFeedback",
22
+ Repos: []string{"payments"},
23
+ Nodes: map[string]workflow.Node{
24
+ "start": {OnSuccess: []workflow.Route{{Target: "implement"}}},
25
+ "implement": {
26
+ Type: workflow.NodeAgent, Agent: "build", Description: "implement",
27
+ OnSuccess: []workflow.Route{{Target: "end"}},
28
+ OnFailure: []workflow.Route{{Target: "implement"}},
29
+ },
30
+ "end": {},
31
+ },
32
+ }
33
+ rid := identity.NewRunID("payments", wf.Name, "PAY-101")
34
+ if _, err := engine.EnsureRun(context.Background(), run.Start{
35
+ ID: rid, Repo: "payments", RepoPath: "/srv/payments", Workflow: wf,
36
+ Ticket: task.TicketRef{ID: "demo-parent", Key: "demo-parent", Title: "parent"},
37
+ }); err != nil {
38
+ t.Fatal(err)
39
+ }
40
+ waitForEndFeedbackTest(t, engine, rid, func(r run.Run) bool {
41
+ return r.CurrentNode == "implement"
42
+ })
43
+
44
+ if _, err := engine.SubmitReport(context.Background(), run.ReportRequest{
45
+ RunID: rid, Node: "implement", ReportID: "end-feedback-test",
46
+ Report: endFeedbackSuccessReport(),
47
+ }); err != nil {
48
+ t.Fatal(err)
49
+ }
50
+ waitForEndFeedbackTest(t, engine, rid, func(r run.Run) bool {
51
+ return r.State == run.StateCompleted
52
+ })
53
+
54
+ events := log.all()
55
+ if !hasEvent(events, "comment:demo-parent-implement") {
56
+ t.Fatalf("current summary comment missing: %v", events)
57
+ }
58
+ if countEvent(events, "comment:demo-parent-implement") != 1 {
59
+ t.Fatalf("summary comment count = %d; events=%v", countEvent(events, "comment:demo-parent-implement"), events)
60
+ }
61
+ if hasEventPrefixExcept(events, "comment:", "comment:demo-parent-implement") {
62
+ t.Fatalf("end selection wrote feedback comment; events=%v", events)
63
+ }
64
+ }
65
+
66
+ func endFeedbackSuccessReport() workflow.Report {
67
+ none := "None"
68
+ return workflow.Report{
69
+ Status: workflow.OutcomeSuccess, NextStep: "end",
70
+ Summary: workflow.Summary{
71
+ Completed: "done", Commits: "abc123", NotCompleted: none,
72
+ IssuesDiscovered: none, Verification: "tested", Notes: none,
73
+ },
74
+ Feedback: workflow.Feedback{
75
+ ReasonForNextStep: none, RequiredActions: none,
76
+ RelevantContext: none, ExpectedResult: none,
77
+ },
78
+ }
79
+ }
80
+
81
+ func newEndFeedbackTestEngine(t *testing.T, sys task.System) *goworkflows.Engine {
82
+ t.Helper()
83
+ return newEngine(t, goworkflows.Dependencies{
84
+ Repos: repoRegistryWith("payments", sys),
85
+ Runner: newFakeRunner(newEventLog()),
86
+ Harness: newFakeHarness(newEventLog()),
87
+ })
88
+ }
89
+
90
+ func waitForEndFeedbackTest(t *testing.T, engine *goworkflows.Engine, id run.ID, predicate func(run.Run) bool) {
91
+ t.Helper()
92
+ waitFor(t, 10*time.Second, func() bool {
93
+ r, err := engine.GetRun(context.Background(), id)
94
+ return err == nil && predicate(r)
95
+ })
96
+ }
97
+
98
+ func hasEvent(events []string, want string) bool {
99
+ for _, event := range events {
100
+ if event == want {
101
+ return true
102
+ }
103
+ }
104
+ return false
105
+ }
106
+
107
+ func countEvent(events []string, want string) int {
108
+ count := 0
109
+ for _, event := range events {
110
+ if event == want {
111
+ count++
112
+ }
113
+ }
114
+ return count
115
+ }
116
+
117
+ func hasEventPrefixExcept(events []string, prefix, allowed string) bool {
118
+ for _, event := range events {
119
+ if strings.HasPrefix(event, prefix) && event != allowed {
120
+ return true
121
+ }
122
+ }
123
+ return false
124
+ }
@@ -24,6 +24,7 @@ import (
24
24
  "github.com/google/uuid"
25
25
 
26
26
  "github.com/rajpopat27/relay-flow/internal/harness"
27
+ "github.com/rajpopat27/relay-flow/internal/identity"
27
28
  "github.com/rajpopat27/relay-flow/internal/repo"
28
29
  "github.com/rajpopat27/relay-flow/internal/run"
29
30
  "github.com/rajpopat27/relay-flow/internal/runner"
@@ -220,6 +221,7 @@ func (e *Engine) registerActivities() error {
220
221
  a := e.activities
221
222
  for _, act := range []goworkflow.Activity{
222
223
  a.EnsureMailboxes,
224
+ a.PrepareRestart,
223
225
  a.ValidateAgents,
224
226
  a.ApplyTaskConfig,
225
227
  a.EnsureEnvironment,
@@ -283,6 +285,12 @@ func (e *Engine) Shutdown(ctx context.Context) error {
283
285
  // title and sends the reconcile signal only when that terminal is missing
284
286
  // or unusable. Repeated polls are harmless.
285
287
  func (e *Engine) EnsureRun(ctx context.Context, start run.Start) (bool, error) {
288
+ if start.LogicalID == "" {
289
+ start.LogicalID = run.ID(identity.LogicalRunID(start.ID))
290
+ }
291
+ if start.AttemptID == 0 {
292
+ start.AttemptID = 1
293
+ }
286
294
  r, err := e.runs.get(ctx, start.ID)
287
295
  if errors.Is(err, errRunNotFound) {
288
296
  start.Runtime = e.runtime
@@ -358,6 +366,18 @@ func (e *Engine) EnsureRun(ctx context.Context, start run.Start) (bool, error) {
358
366
  // Attrs always carry ticket/runID/node/nodeVisitID when known.
359
367
  func (e *Engine) SubmitReport(ctx context.Context, req run.ReportRequest) (run.ReportAck, error) {
360
368
  r, err := e.runs.get(ctx, req.RunID)
369
+ if errors.Is(err, errRunNotFound) {
370
+ // A retained newer attempt can outlive an old attempt row. Resolve the
371
+ // stable logical ID and acknowledge the old attempt as a stale
372
+ // duplicate; it must never be validated or signaled into the new run.
373
+ logicalID := run.ID(identity.LogicalRunID(req.RunID))
374
+ if latest, lookupErr := e.runs.findByLogicalID(ctx, logicalID); lookupErr == nil && latest.ID != req.RunID {
375
+ slog.Info("report duplicate ack", "ticket", latest.Ticket.Key,
376
+ "runID", string(req.RunID), "logicalRunID", string(logicalID),
377
+ "node", req.Node, "reportID", req.ReportID, "state", string(latest.State))
378
+ return run.ReportAck{Accepted: true, Duplicate: true}, nil
379
+ }
380
+ }
361
381
  if err != nil {
362
382
  return run.ReportAck{}, fmt.Errorf("resolve run %s: %w", req.RunID, err)
363
383
  }
@@ -9,6 +9,8 @@ import (
9
9
  "time"
10
10
 
11
11
  "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
12
+ "github.com/rajpopat27/relay-flow/internal/harness"
13
+ "github.com/rajpopat27/relay-flow/internal/harness/opencode"
12
14
  "github.com/rajpopat27/relay-flow/internal/identity"
13
15
  "github.com/rajpopat27/relay-flow/internal/repo"
14
16
  "github.com/rajpopat27/relay-flow/internal/run"
@@ -44,33 +46,34 @@ func TestMailboxDescriptionAndLaunchPromptAreTaskSystemNeutral(t *testing.T) {
44
46
  wf := linearWorkflow(false)
45
47
  node := wf.Nodes["coding"]
46
48
  node.Type = workflow.NodeHITL
47
- description := goworkflows.MailboxSpecForNode(&wf, "PAY-101", "coding", node).Description
49
+ spec := goworkflows.MailboxSpecForNode(&wf, "PAY-101", "coding", node)
50
+ description := spec.Description
48
51
  for _, want := range []string{
49
- "Parent ticket: PAY-101",
50
- "Do not make code changes",
51
- "until the human is satisfied with the review",
52
+ "Required report format:",
53
+ "Node names identify workflow stages",
54
+ "SUMMARY is written to this current mailbox",
55
+ "requested changes in FEEDBACK",
52
56
  } {
53
57
  if !strings.Contains(description, want) {
54
58
  t.Fatalf("HITL mailbox description missing %q:\n%s", want, description)
55
59
  }
56
60
  }
57
- for _, unwanted := range []string{"Jira", "OpenCode", "Question tool"} {
61
+ for _, unwanted := range []string{"Jira", "OpenCode", "Question tool", "Approve and Reject", "Discuss the task with the human"} {
58
62
  if strings.Contains(description, unwanted) {
59
63
  t.Fatalf("generic mailbox description contains %q:\n%s", unwanted, description)
60
64
  }
61
65
  }
62
66
 
63
- node.Type = workflow.NodeAgent
64
- agentDescription := goworkflows.MailboxSpecForNode(&wf, "PAY-101", "coding", node).Description
65
- if strings.Contains(agentDescription, "Question tool") {
66
- t.Fatalf("agent mailbox description contains HITL Question instruction:\n%s", agentDescription)
67
- }
68
-
69
67
  for _, taskSystem := range []string{"jira", "linear"} {
70
- prompt := goworkflows.BuildLaunchSpecPrompt(taskSystem, "PAY-101", "PAY-234")
71
- want := "Task system: " + taskSystem + "\nUse the " + taskSystem + " tools to read the parent ticket PAY-101.\n\nYour mailbox is PAY-234. Read its description and comments for node instructions and feedback."
68
+ prompt, err := opencode.New().RenderPrompt(harness.PromptInitial, harness.PromptData{
69
+ TaskSystem: taskSystem, Ticket: "PAY-101", Mailbox: "PAY-234", NodeType: workflow.NodeHITL,
70
+ }, "")
71
+ if err != nil {
72
+ t.Fatal(err)
73
+ }
74
+ want := "Task system: " + taskSystem + "\nUse the " + taskSystem + " tools to read the parent ticket PAY-101.\n\nYour mailbox is PAY-234. Read its description and comments for node instructions and feedback.\n\nReturn the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval."
72
75
  if prompt != want {
73
- t.Fatalf("BuildLaunchSpecPrompt(%s) = %q, want %q", taskSystem, prompt, want)
76
+ t.Fatalf("RenderPrompt(%s) = %q, want %q", taskSystem, prompt, want)
74
77
  }
75
78
  if strings.Contains(prompt, "Jira") || strings.Contains(prompt, "subtask") {
76
79
  t.Fatalf("launch prompt contains task-system-specific mailbox wording: %q", prompt)
@@ -182,7 +185,11 @@ func TestRunBeginsAtStartAndFollowsEntryEdge(t *testing.T) {
182
185
  Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: fh,
183
186
  })
184
187
 
185
- rid, err := startRun(engine, linearWorkflow(false))
188
+ wf := linearWorkflow(false)
189
+ node := wf.Nodes["coding"]
190
+ node.NudgePrompt = "Continue {{ticket}} at {{node}}. Valid next steps: {{nextSteps}}."
191
+ wf.Nodes["coding"] = node
192
+ rid, err := startRun(engine, wf)
186
193
  if err != nil {
187
194
  t.Fatalf("EnsureRun failed: %v", err)
188
195
  }
@@ -206,6 +213,17 @@ func TestRunBeginsAtStartAndFollowsEntryEdge(t *testing.T) {
206
213
  if runtime.TerminalID == "" || runtime.NodeVisitID != r.CurrentNodeVisitID {
207
214
  t.Fatalf("terminal was not persisted for current visit: %+v", runtime)
208
215
  }
216
+ promptCalls := fh.promptCalls()
217
+ if len(promptCalls) != 1 {
218
+ t.Fatalf("RenderPrompt calls = %+v, want one initial prompt", promptCalls)
219
+ }
220
+ call := promptCalls[0]
221
+ if call.NudgeTemplate != node.NudgePrompt {
222
+ t.Fatalf("nudge passed to harness = %q, want raw template %q", call.NudgeTemplate, node.NudgePrompt)
223
+ }
224
+ if call.Data.Ticket != "PAY-101" || call.Data.Workflow != wf.Name || call.Data.Repo != "payments" || call.Data.Node != "coding" || call.Data.NextSteps == "" {
225
+ t.Fatalf("nudge prompt data = %+v, want current workflow values", call.Data)
226
+ }
209
227
 
210
228
  // Pre-edge gate: before following the start edge the run ensures the
211
229
  // runner environment AND validates every referenced agent, and applies
@@ -74,6 +74,7 @@ type fakeTaskSystem struct {
74
74
  specs []task.MailboxSpec
75
75
  comments []recordedComment
76
76
  resets []string
77
+ renderText func(task.TextKind, task.TextData) (string, error)
77
78
 
78
79
  // Failure/crash/slow injection — the fake IS the documented injection
79
80
  // seam (allowed seam a). These make the fake adapter fail/stall so the
@@ -81,6 +82,7 @@ type fakeTaskSystem struct {
81
82
  completeFail int // next N CompleteMailbox calls return transient error
82
83
  completeConflict bool // CompleteMailbox returns retry.ConflictError
83
84
  completeSlow time.Duration // CompleteMailbox sleeps (a running activity)
85
+ startConflict bool // start ApplyTaskConfig returns a status conflict
84
86
  failComments bool // Comment returns transient error
85
87
 
86
88
  // Recovery fixtures.
@@ -122,6 +124,22 @@ func (s *fakeTaskSystem) ValidateConfig(context.Context, config.RawValues, map[s
122
124
  return nil
123
125
  }
124
126
 
127
+ func (s *fakeTaskSystem) RenderText(kind task.TextKind, data task.TextData) (string, error) {
128
+ if s.renderText != nil {
129
+ return s.renderText(kind, data)
130
+ }
131
+ switch kind {
132
+ case task.TextMailboxDescription:
133
+ return "Parent ticket: " + data.Ticket + "\nNode: " + data.Node + "\nType: " + data.NodeType + "\nAgent: " + data.Agent + "\nWork: " + data.NodeDescription + "\nMailbox: " + data.Mailbox, nil
134
+ case task.TextSummaryComment:
135
+ return "SUMMARY\n" + data.SummaryReport, nil
136
+ case task.TextFeedbackComment:
137
+ return "Feedback from " + data.SourceNode + " to " + data.TargetNode + " mailbox " + data.Mailbox + "\n" + data.FeedbackReport, nil
138
+ default:
139
+ return "", fmt.Errorf("unknown task text kind %q", kind)
140
+ }
141
+ }
142
+
125
143
  func (s *fakeTaskSystem) EnsureMailboxes(_ context.Context, parent task.TicketRef, wf string, specs []task.MailboxSpec) (map[string]task.Mailbox, error) {
126
144
  s.log.add("ensureMailboxes:" + parent.Key)
127
145
  s.mu.Lock()
@@ -148,6 +166,15 @@ func (s *fakeTaskSystem) EnsureMailboxes(_ context.Context, parent task.TicketRe
148
166
 
149
167
  func (s *fakeTaskSystem) ApplyTaskConfig(_ context.Context, target task.Target, cfg config.RawValues) error {
150
168
  key := target.Parent.Key
169
+ if target.Mailbox == nil {
170
+ s.mu.Lock()
171
+ conflict := s.startConflict
172
+ s.mu.Unlock()
173
+ if conflict {
174
+ s.log.add("applyTaskConfigConflict:" + key)
175
+ return retry.ConflictError(errStartConflict)
176
+ }
177
+ }
151
178
  if target.Mailbox != nil {
152
179
  key = target.Mailbox.Key
153
180
  s.mu.Lock()
@@ -210,6 +237,16 @@ func (s *fakeTaskSystem) Comment(_ context.Context, target task.Target, body, ma
210
237
  return nil
211
238
  }
212
239
 
240
+ func (s *fakeTaskSystem) PrepareRestart(_ context.Context, parent task.TicketRef, mbs []task.Mailbox) error {
241
+ s.mu.Lock()
242
+ for _, mb := range mbs {
243
+ s.mailboxStatus[mb.Key] = "To Do"
244
+ }
245
+ s.mu.Unlock()
246
+ s.log.add("prepareRestart:" + parent.Key)
247
+ return nil
248
+ }
249
+
213
250
  func (s *fakeTaskSystem) ResetForRecovery(_ context.Context, parent task.TicketRef, mbs []task.Mailbox, _ config.RawValues) error {
214
251
  s.mu.Lock()
215
252
  for _, mb := range mbs {
@@ -270,6 +307,12 @@ func (s *fakeTaskSystem) setMailboxStatus(key, status string) {
270
307
  s.mu.Unlock()
271
308
  }
272
309
 
310
+ func (s *fakeTaskSystem) setStartConflict(value bool) {
311
+ s.mu.Lock()
312
+ s.startConflict = value
313
+ s.mu.Unlock()
314
+ }
315
+
273
316
  // seedMailbox pre-populates an existing mailbox (with labels) for recovery
274
317
  // and reuse tests.
275
318
  func (s *fakeTaskSystem) seedMailbox(parentKey string, mb task.Mailbox, labels []string) {
@@ -328,11 +371,12 @@ func (f *fakeRunner) ValidateRepo(context.Context, string, string) error { retur
328
371
  func (f *fakeRunner) EnsureEnvironment(_ context.Context, spec runner.RunSpec) (runner.Environment, error) {
329
372
  f.mu.Lock()
330
373
  defer f.mu.Unlock()
331
- if e, ok := f.envs[string(spec.RunID)]; ok {
374
+ key := spec.TicketKey
375
+ if e, ok := f.envs[key]; ok {
332
376
  return e, nil
333
377
  }
334
- e := runner.Environment{ID: "env-" + string(spec.RunID), Path: spec.RepoPath}
335
- f.envs[string(spec.RunID)] = e
378
+ e := runner.Environment{ID: "env-" + spec.TicketKey, Path: spec.RepoPath}
379
+ f.envs[key] = e
336
380
  f.log.add("ensureEnvironment:" + string(spec.RunID))
337
381
  return e, nil
338
382
  }
@@ -401,7 +445,7 @@ func (f *fakeRunner) CloseTerminal(_ context.Context, t runner.Terminal) error {
401
445
  func (f *fakeRunner) CloseTerminals(_ context.Context, spec runner.RunSpec) error {
402
446
  f.mu.Lock()
403
447
  defer f.mu.Unlock()
404
- prefix := "env-" + string(spec.RunID) + "/"
448
+ prefix := "env-" + spec.TicketKey + "/"
405
449
  for k, ft := range f.terminals {
406
450
  if len(k) >= len(prefix) && k[:len(prefix)] == prefix {
407
451
  ft.live = false
@@ -416,13 +460,13 @@ func (f *fakeRunner) CloseTerminals(_ context.Context, spec runner.RunSpec) erro
416
460
  func (f *fakeRunner) CleanupRun(_ context.Context, spec runner.RunSpec) error {
417
461
  f.mu.Lock()
418
462
  defer f.mu.Unlock()
419
- prefix := "env-" + string(spec.RunID) + "/"
463
+ prefix := "env-" + spec.TicketKey + "/"
420
464
  for k := range f.terminals {
421
465
  if len(k) >= len(prefix) && k[:len(prefix)] == prefix {
422
466
  delete(f.terminals, k)
423
467
  }
424
468
  }
425
- delete(f.envs, string(spec.RunID))
469
+ delete(f.envs, spec.TicketKey)
426
470
  f.cleaned = append(f.cleaned, string(spec.RunID))
427
471
  f.log.add("cleanupRun:" + string(spec.RunID))
428
472
  return nil
@@ -455,16 +499,25 @@ func (f *fakeRunner) killTerminals() {
455
499
  type fakeHarness struct {
456
500
  log *eventLog
457
501
 
458
- mu sync.Mutex
459
- validated []string
460
- sessions map[string]harness.Session
461
- reconcileNudge int // nudges sent to idle live HITL sessions (must stay 0)
502
+ mu sync.Mutex
503
+ validated []string
504
+ sessions map[string]harness.Session
505
+ renderedPrompts []renderedPromptCall
506
+ reconcileNudge int // nudges sent to idle live HITL sessions (must stay 0)
507
+ }
508
+
509
+ type renderedPromptCall struct {
510
+ Kind harness.PromptKind
511
+ Data harness.PromptData
512
+ NudgeTemplate string
462
513
  }
463
514
 
464
515
  func newFakeHarness(log *eventLog) *fakeHarness {
465
516
  return &fakeHarness{log: log, sessions: map[string]harness.Session{}}
466
517
  }
467
518
 
519
+ func (f *fakeHarness) SetupRepo(context.Context, string) error { return nil }
520
+
468
521
  func (f *fakeHarness) ValidateAgent(_ context.Context, _, agent string) error {
469
522
  f.mu.Lock()
470
523
  f.validated = append(f.validated, agent)
@@ -481,6 +534,26 @@ func (f *fakeHarness) FindSession(_ context.Context, _, title string) (harness.S
481
534
  return s, ok, nil
482
535
  }
483
536
 
537
+ func (f *fakeHarness) RenderPrompt(kind harness.PromptKind, data harness.PromptData, nudge string) (string, error) {
538
+ f.mu.Lock()
539
+ f.renderedPrompts = append(f.renderedPrompts, renderedPromptCall{Kind: kind, Data: data, NudgeTemplate: nudge})
540
+ f.mu.Unlock()
541
+ prompt := string(kind) + ":" + data.TaskSystem + ":" + data.Mailbox
542
+ if data.NodeType == workflow.NodeHITL {
543
+ prompt += ":hitl"
544
+ }
545
+ if nudge != "" {
546
+ prompt += ":" + nudge
547
+ }
548
+ return prompt, nil
549
+ }
550
+
551
+ func (f *fakeHarness) promptCalls() []renderedPromptCall {
552
+ f.mu.Lock()
553
+ defer f.mu.Unlock()
554
+ return append([]renderedPromptCall(nil), f.renderedPrompts...)
555
+ }
556
+
484
557
  func (f *fakeHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
485
558
  f.log.add("buildCommand:" + spec.Node + ":" + string(spec.NodeVisitID) + ":resume=" + spec.ResumeID)
486
559
  return runner.Command{Executable: "opencode"}, nil
@@ -507,3 +580,4 @@ type conflictError struct{ msg string }
507
580
  func (e *conflictError) Error() string { return e.msg }
508
581
 
509
582
  var errConflict = &conflictError{msg: "human moved mailbox"}
583
+ var errStartConflict = &conflictError{msg: "human moved ticket status"}
@@ -57,10 +57,18 @@ func jitter(ctx goworkflow.Context) (float64, error) {
57
57
  // deterministic. On cancellation it runs cancellation cleanup on a
58
58
  // disconnected context.
59
59
  func (a *Activities) TicketWorkflow(ctx goworkflow.Context, start run.Start) error {
60
+ if start.LogicalID == "" {
61
+ start.LogicalID = run.ID(identity.LogicalRunID(start.ID))
62
+ }
63
+ if start.AttemptID == 0 {
64
+ start.AttemptID = 1
65
+ }
60
66
  err := a.runGraph(ctx, start)
61
67
  if err != nil && ctx.Err() != nil {
62
68
  work := run.Work{
63
69
  RunID: start.ID,
70
+ LogicalID: start.LogicalID,
71
+ AttemptID: start.AttemptID,
64
72
  Repo: start.Repo,
65
73
  Workflow: start.Workflow.Name,
66
74
  Parent: start.Ticket,
@@ -76,6 +84,8 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
76
84
  wf := start.Workflow // value snapshot
77
85
  work := run.Work{
78
86
  RunID: start.ID,
87
+ LogicalID: start.LogicalID,
88
+ AttemptID: start.AttemptID,
79
89
  Repo: start.Repo,
80
90
  Workflow: wf.Name,
81
91
  Parent: start.Ticket,
@@ -93,6 +103,26 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
93
103
  return err
94
104
  }
95
105
 
106
+ // An explicit restart reuses the task-system mailboxes and ticket
107
+ // worktree, but resets relay-owned mailbox state and closes stale node
108
+ // terminals before the fresh start edge is processed. Human-owned
109
+ // incompatible states are returned as conflicts and keep this attempt
110
+ // blocked until the human restores a compatible state.
111
+ if start.AttemptID > 1 {
112
+ mailboxList := make([]task.Mailbox, 0, len(mailboxes))
113
+ for _, mailbox := range mailboxes {
114
+ mailboxList = append(mailboxList, mailbox)
115
+ }
116
+ sort.Slice(mailboxList, func(i, j int) bool { return mailboxList[i].Node < mailboxList[j].Node })
117
+ if _, err := retryLoop(ctx, start.ID, a, work, "start",
118
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
119
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries,
120
+ a.PrepareRestart, work, start.RepoPath, mailboxList)
121
+ }); err != nil {
122
+ return err
123
+ }
124
+ }
125
+
96
126
  // Validate every referenced agent before the start edge.
97
127
  agentSet := map[string]bool{}
98
128
  for _, n := range wf.Nodes {
@@ -188,22 +218,9 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
188
218
 
189
219
  title := start.Ticket.Key + ":" + current
190
220
 
191
- // Build the finished launch metadata from the workflow snapshot: the
192
- // prompt carries the node description, optional custom instructions,
193
- // complete report contract, and valid next steps. Custom instructions
194
- // are also retained separately for live-terminal revisits.
221
+ // Build task-system-neutral prompt data from the workflow snapshot. The
222
+ // selected harness owns rendering initial, feedback, and HITL text.
195
223
  nextSteps := append(append([]workflow.Route{}, node.OnSuccess...), node.OnFailure...)
196
- nudge, err := wf.RenderNudge(current, workflow.NudgeTemplateData{
197
- TaskSystem: a.TaskSystem,
198
- Ticket: start.Ticket.Key,
199
- Workflow: wf.Name,
200
- Repo: start.Repo,
201
- Node: current,
202
- NextSteps: nextStepsText(nextSteps),
203
- })
204
- if err != nil {
205
- return err
206
- }
207
224
  spec := harness.LaunchSpec{
208
225
  RunID: start.ID,
209
226
  NodeVisitID: visitID,
@@ -215,9 +232,20 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
215
232
  NodeType: node.Type,
216
233
  Agent: node.Agent,
217
234
  Title: title,
218
- Prompt: BuildLaunchSpecPrompt(a.TaskSystem, start.Ticket.Key, mb.Key),
219
- NudgePrompt: nudge,
220
- NextSteps: nextSteps,
235
+ NudgePrompt: node.NudgePrompt,
236
+ PromptData: harness.PromptData{
237
+ TaskSystem: a.TaskSystem,
238
+ Ticket: start.Ticket.Key,
239
+ Workflow: wf.Name,
240
+ Repo: start.Repo,
241
+ Node: current,
242
+ NodeType: node.Type,
243
+ Agent: node.Agent,
244
+ NodeDescription: node.Description,
245
+ NextSteps: nextStepsText(nextSteps),
246
+ Mailbox: mb.Key,
247
+ },
248
+ NextSteps: nextSteps,
221
249
  }
222
250
  if runtime.SessionID != "" {
223
251
  spec.ResumeID = runtime.SessionID
@@ -305,10 +333,14 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
305
333
  // complete current -> process next node.
306
334
  if _, err := retryLoop(ctx, start.ID, a, work, current,
307
335
  func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
336
+ summaryReport := renderSummaryReport(report)
308
337
  return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.Comment, start.Repo, run.CommentWork{
309
- RunID: start.ID,
310
- Item: task.Target{Parent: work.Parent, Mailbox: &mb},
311
- Body: renderSummary(report),
338
+ RunID: start.ID, Item: task.Target{Parent: work.Parent, Mailbox: &mb},
339
+ TextKind: task.TextSummaryComment,
340
+ TextData: task.TextData{RunID: string(start.ID), Ticket: work.Parent.Key,
341
+ Workflow: wf.Name, Repo: start.Repo, Node: current, NodeType: string(node.Type),
342
+ Agent: node.Agent, NodeDescription: node.Description, Mailbox: mb.Key,
343
+ SourceNode: current, TargetNode: current, SummaryReport: summaryReport},
312
344
  Marker: string(visitID) + ":summary",
313
345
  })
314
346
  }); err != nil {
@@ -320,10 +352,15 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
320
352
  nextMb := mailboxes[next]
321
353
  if _, err := retryLoop(ctx, start.ID, a, work, current,
322
354
  func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
355
+ feedbackReport := renderFeedbackReport(report)
323
356
  return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.Comment, start.Repo, run.CommentWork{
324
- RunID: start.ID,
325
- Item: task.Target{Parent: work.Parent, Mailbox: &nextMb},
326
- Body: renderFeedback(current, report),
357
+ RunID: start.ID, Item: task.Target{Parent: work.Parent, Mailbox: &nextMb},
358
+ TextKind: task.TextFeedbackComment,
359
+ TextData: task.TextData{RunID: string(start.ID), Ticket: work.Parent.Key,
360
+ Workflow: wf.Name, Repo: start.Repo, Node: next, NodeType: string(wf.Nodes[next].Type),
361
+ Agent: wf.Nodes[next].Agent, NodeDescription: wf.Nodes[next].Description, Mailbox: nextMb.Key,
362
+ SourceNode: current, TargetNode: next, SummaryReport: renderSummaryReport(report),
363
+ FeedbackReport: feedbackReport},
327
364
  Marker: string(visitID) + ":feedback",
328
365
  })
329
366
  }); err != nil {
@@ -436,6 +473,7 @@ func retryLoop[T any](ctx goworkflow.Context, id run.ID, a *Activities, work run
436
473
  }
437
474
  f := classifyActivityError(err)
438
475
  if f.Kind == retry.Conflict {
476
+ f.Message = blockedMessage(work, node, f.Message)
439
477
  // Mark blocked, keep retrying on the capped schedule.
440
478
  _, _ = scheduleState(ctx, a, id, run.StateBlocked, f.Message)
441
479
  blocked = true
@@ -456,6 +494,18 @@ func retryLoop[T any](ctx goworkflow.Context, id run.ID, a *Activities, work run
456
494
  }
457
495
  }
458
496
 
497
+ func blockedMessage(work run.Work, node, message string) string {
498
+ message = strings.TrimRight(message, ". ")
499
+ lower := strings.ToLower(message)
500
+ if node == "start" && !strings.Contains(lower, "mailbox") {
501
+ return fmt.Sprintf("%s. Move ticket %s to an allowed active start status; relay-flow will retry automatically", message, work.Parent.Key)
502
+ }
503
+ if node != "" {
504
+ return fmt.Sprintf("%s. Restore the task-system state required for node %s; relay-flow will retry automatically", message, node)
505
+ }
506
+ return fmt.Sprintf("%s. Restore the task-system state required by this operation; relay-flow will retry automatically", message)
507
+ }
508
+
459
509
  // logRetry emits the 9.6 retry-classification info line, replay-safe.
460
510
  // Attrs come from the always-known run.Work value carried by the caller,
461
511
  // so ticket/repo/workflow are present even if the projection is briefly
@@ -558,6 +608,10 @@ func mustJitter(ctx goworkflow.Context) float64 {
558
608
  // canceled. No rollback/compensation ever runs.
559
609
  func (a *Activities) cancelCleanup(ctx goworkflow.Context, work run.Work, repoPath, reason string) error {
560
610
  dctx := goworkflow.NewDisconnectedContext(ctx)
611
+ markerID := work.LogicalID
612
+ if markerID == "" {
613
+ markerID = work.RunID
614
+ }
561
615
  if _, err := retryLoop(dctx, work.RunID, a, work, "",
562
616
  func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
563
617
  return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries,
@@ -571,7 +625,7 @@ func (a *Activities) cancelCleanup(ctx goworkflow.Context, work run.Work, repoPa
571
625
  RunID: work.RunID,
572
626
  Item: task.Target{Parent: work.Parent},
573
627
  Body: "Run canceled: " + reason,
574
- Marker: run.CancellationMarker(work.RunID),
628
+ Marker: run.CancellationMarker(markerID),
575
629
  })
576
630
  }); err != nil {
577
631
  return err
@@ -602,12 +656,12 @@ func nextStepsText(routes []workflow.Route) string {
602
656
  return b.String()
603
657
  }
604
658
 
605
- func renderSummary(r workflow.Report) string {
606
- return fmt.Sprintf("SUMMARY\nCOMPLETED:\n%s\n\nCOMMITS:\n%s\n\nNOT COMPLETED:\n%s\n\nISSUES DISCOVERED:\n%s\n\nVERIFICATION:\n%s\n\nNOTES:\n%s\n",
659
+ func renderSummaryReport(r workflow.Report) string {
660
+ return fmt.Sprintf("COMPLETED:\n%s\n\nCOMMITS:\n%s\n\nNOT COMPLETED:\n%s\n\nISSUES DISCOVERED:\n%s\n\nVERIFICATION:\n%s\n\nNOTES:\n%s",
607
661
  r.Summary.Completed, r.Summary.Commits, r.Summary.NotCompleted, r.Summary.IssuesDiscovered, r.Summary.Verification, r.Summary.Notes)
608
662
  }
609
663
 
610
- func renderFeedback(source string, r workflow.Report) string {
611
- return fmt.Sprintf("Feedback from %s\nCOMMITS:\n%s\n\nREASON FOR NEXT STEP:\n%s\n\nREQUIRED ACTIONS:\n%s\n\nRELEVANT CONTEXT:\n%s\n\nEXPECTED RESULT:\n%s\n",
612
- source, r.Summary.Commits, r.Feedback.ReasonForNextStep, r.Feedback.RequiredActions, r.Feedback.RelevantContext, r.Feedback.ExpectedResult)
664
+ func renderFeedbackReport(r workflow.Report) string {
665
+ return fmt.Sprintf("COMMITS:\n%s\n\nREASON FOR NEXT STEP:\n%s\n\nREQUIRED ACTIONS:\n%s\n\nRELEVANT CONTEXT:\n%s\n\nEXPECTED RESULT:\n%s",
666
+ r.Summary.Commits, r.Feedback.ReasonForNextStep, r.Feedback.RequiredActions, r.Feedback.RelevantContext, r.Feedback.ExpectedResult)
613
667
  }