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.
- package/README.md +193 -25
- package/cmd/relay-flow/beads_composition_test.go +451 -0
- package/cmd/relay-flow/commands_test.go +79 -1
- package/cmd/relay-flow/main.go +50 -1
- package/cmd/relay-flow/scenario_test.go +48 -12
- package/cmd/relay-flow/serve.go +16 -2
- package/examples/beads-workflow.yaml +74 -0
- package/examples/default-story-workflow.yaml +6 -6
- package/go.mod +2 -1
- package/go.sum +2 -0
- package/internal/config/config.go +13 -2
- package/internal/config/merge_test.go +18 -0
- package/internal/execution/goworkflows/activities.go +124 -56
- package/internal/execution/goworkflows/end_feedback_test.go +124 -0
- package/internal/execution/goworkflows/engine.go +20 -0
- package/internal/execution/goworkflows/engine_test.go +33 -15
- package/internal/execution/goworkflows/fakes_test.go +84 -10
- package/internal/execution/goworkflows/interpreter.go +84 -30
- package/internal/execution/goworkflows/mailbox_test.go +85 -0
- package/internal/execution/goworkflows/node_runtime_test.go +30 -5
- package/internal/execution/goworkflows/projection.go +52 -11
- package/internal/execution/goworkflows/recovery_test.go +131 -2
- package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
- package/internal/harness/contract_test.go +10 -0
- package/internal/harness/factory.go +25 -3
- package/internal/harness/harness.go +30 -4
- package/internal/harness/opencode/opencode.go +125 -10
- package/internal/harness/opencode/opencode_test.go +238 -0
- package/internal/harness/opencode/repo_setup.go +401 -0
- package/internal/harness/plugin_selection_test.go +5 -5
- package/internal/identity/identity.go +28 -1
- package/internal/identity/identity_test.go +40 -0
- package/internal/recover/recover.go +11 -6
- package/internal/repo/service.go +10 -0
- package/internal/repo/service_test.go +51 -2
- package/internal/run/manager.go +193 -25
- package/internal/run/run.go +30 -10
- package/internal/run/run_manager_test.go +100 -0
- package/internal/server/api_test.go +54 -0
- package/internal/server/client.go +11 -0
- package/internal/server/fixture_test.go +18 -0
- package/internal/server/server.go +16 -0
- package/internal/task/beads/bdcli/bdcli.go +323 -0
- package/internal/task/beads/bdcli/bdcli_test.go +297 -0
- package/internal/task/beads/bdcli/testdata/array.json +1 -0
- package/internal/task/beads/bdcli/testdata/children.json +1 -0
- package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
- package/internal/task/beads/bdcli/testdata/commented.json +1 -0
- package/internal/task/beads/bdcli/testdata/comments.json +1 -0
- package/internal/task/beads/bdcli/testdata/created.json +1 -0
- package/internal/task/beads/bdcli/testdata/empty.json +1 -0
- package/internal/task/beads/bdcli/testdata/object.json +1 -0
- package/internal/task/beads/bdcli/testdata/ready.json +1 -0
- package/internal/task/beads/bdcli/testdata/show.json +1 -0
- package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
- package/internal/task/beads/bdcli/testdata/updated.json +1 -0
- package/internal/task/beads/beads.go +856 -0
- package/internal/task/beads/beads_test.go +609 -0
- package/internal/task/beads/comments_test.go +242 -0
- package/internal/task/beads/config_compatibility_test.go +163 -0
- package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
- package/internal/task/beads/repo_composition_test.go +232 -0
- package/internal/task/beads/runtime_config_test.go +81 -0
- package/internal/task/beads/status_compatibility_test.go +276 -0
- package/internal/task/beads/status_test.go +257 -0
- package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
- package/internal/task/beads/validation_test.go +110 -0
- package/internal/task/contract_test.go +10 -0
- package/internal/task/factory.go +37 -4
- package/internal/task/jira/auth.go +27 -1
- package/internal/task/jira/auth_test.go +54 -1
- package/internal/task/jira/filters_test.go +60 -0
- package/internal/task/jira/helpers_test.go +3 -0
- package/internal/task/jira/jira.go +187 -29
- package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
- package/internal/task/jira/rest/adf.go +119 -82
- package/internal/task/jira/rest/adf_test.go +60 -0
- package/internal/task/jira/rest/client_test.go +1 -1
- package/internal/task/jira/templates_test.go +118 -0
- package/internal/task/jira/transition_defaults_test.go +47 -2
- package/internal/task/task.go +40 -0
- package/internal/workflow/report_test.go +45 -0
- 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
|
-
|
|
49
|
+
spec := goworkflows.MailboxSpecForNode(&wf, "PAY-101", "coding", node)
|
|
50
|
+
description := spec.Description
|
|
48
51
|
for _, want := range []string{
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
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 :=
|
|
71
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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-" +
|
|
335
|
-
f.envs[
|
|
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-" +
|
|
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-" +
|
|
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,
|
|
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
|
|
459
|
-
validated
|
|
460
|
-
sessions
|
|
461
|
-
|
|
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
|
|
192
|
-
//
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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:
|
|
310
|
-
|
|
311
|
-
|
|
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:
|
|
325
|
-
|
|
326
|
-
|
|
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(
|
|
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
|
|
606
|
-
return fmt.Sprintf("
|
|
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
|
|
611
|
-
return fmt.Sprintf("
|
|
612
|
-
|
|
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
|
}
|