relay-flow 0.2.0-alpha → 0.2.2-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +171 -26
- package/cmd/relay-flow/beads_composition_test.go +451 -0
- package/cmd/relay-flow/commands_test.go +593 -15
- package/cmd/relay-flow/main.go +356 -119
- package/cmd/relay-flow/scenario_test.go +246 -35
- package/cmd/relay-flow/serve.go +5 -2
- package/examples/beads-workflow.yaml +74 -0
- package/examples/default-story-workflow.yaml +88 -0
- 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 +136 -87
- package/internal/execution/goworkflows/end_feedback_test.go +124 -0
- package/internal/execution/goworkflows/engine.go +41 -8
- package/internal/execution/goworkflows/engine_test.go +100 -22
- package/internal/execution/goworkflows/fakes_test.go +63 -25
- package/internal/execution/goworkflows/interpreter.go +45 -30
- package/internal/execution/goworkflows/mailbox_test.go +85 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
- package/internal/execution/goworkflows/node_runtime_test.go +72 -23
- package/internal/execution/goworkflows/recovery_test.go +7 -7
- package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
- package/internal/execution/goworkflows/retry_log_test.go +11 -11
- package/internal/harness/contract_test.go +15 -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 +183 -0
- package/internal/harness/opencode/repo_setup.go +361 -0
- package/internal/harness/plugin_selection_test.go +5 -5
- package/internal/paths/paths.go +18 -16
- package/internal/recover/recover.go +11 -6
- package/internal/repo/repo.go +13 -0
- package/internal/repo/service.go +10 -0
- package/internal/repo/service_test.go +55 -6
- package/internal/router/router.go +3 -2
- package/internal/router/router_test.go +87 -0
- package/internal/run/manager.go +14 -1
- package/internal/run/run.go +6 -4
- package/internal/run/run_manager_test.go +21 -1
- package/internal/runner/contract_test.go +64 -26
- package/internal/runner/orca/orca.go +30 -54
- package/internal/runner/orca/orca_test.go +143 -4
- package/internal/runner/orca/orcacli/orcacli.go +5 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
- package/internal/runner/runner.go +15 -8
- package/internal/task/auth_test.go +48 -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/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 +840 -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 +233 -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 +12 -0
- package/internal/task/factory.go +52 -3
- package/internal/task/jira/auth.go +209 -0
- package/internal/task/jira/auth_test.go +160 -0
- package/internal/task/jira/effects_test.go +39 -0
- package/internal/task/jira/filters_test.go +96 -16
- package/internal/task/jira/helpers_test.go +29 -19
- package/internal/task/jira/jira.go +263 -90
- package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
- package/internal/task/jira/normalize.go +32 -14
- package/internal/task/jira/rest/adf.go +165 -0
- package/internal/task/jira/rest/adf_test.go +60 -0
- package/internal/task/jira/rest/client.go +573 -0
- package/internal/task/jira/rest/client_test.go +381 -0
- package/internal/task/jira/templates_test.go +118 -0
- package/internal/task/jira/transition_defaults_test.go +22 -18
- package/internal/task/jira/validation_test.go +1 -1
- package/internal/task/task.go +32 -0
- package/internal/workflow/report_test.go +45 -0
- package/internal/workflow/workflow.go +9 -6
- package/internal/workflow/workflow_test.go +14 -12
- package/package.json +2 -1
- package/internal/task/jira/acli/acli.go +0 -306
- package/internal/task/jira/acli/acli_test.go +0 -208
- package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
- /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
- /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
|
@@ -2,12 +2,15 @@ package goworkflows_test
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
|
+
"database/sql"
|
|
5
6
|
"path/filepath"
|
|
6
7
|
"strings"
|
|
7
8
|
"testing"
|
|
8
9
|
"time"
|
|
9
10
|
|
|
10
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"
|
|
11
14
|
"github.com/rajpopat27/relay-flow/internal/identity"
|
|
12
15
|
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
13
16
|
"github.com/rajpopat27/relay-flow/internal/run"
|
|
@@ -39,38 +42,45 @@ func linearWorkflow(cleanup bool) workflow.Workflow {
|
|
|
39
42
|
}
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
func
|
|
45
|
+
func TestMailboxDescriptionAndLaunchPromptAreTaskSystemNeutral(t *testing.T) {
|
|
43
46
|
wf := linearWorkflow(false)
|
|
44
47
|
node := wf.Nodes["coding"]
|
|
45
48
|
node.Type = workflow.NodeHITL
|
|
46
|
-
|
|
49
|
+
spec := goworkflows.MailboxSpecForNode(&wf, "PAY-101", "coding", node)
|
|
50
|
+
description := spec.Description
|
|
47
51
|
for _, want := range []string{
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"If approved, output the report verbatim",
|
|
53
|
-
"If rejected, return to step 1",
|
|
52
|
+
"Required report format:",
|
|
53
|
+
"Node names identify workflow stages",
|
|
54
|
+
"SUMMARY is written to this current mailbox",
|
|
55
|
+
"requested changes in FEEDBACK",
|
|
54
56
|
} {
|
|
55
57
|
if !strings.Contains(description, want) {
|
|
56
58
|
t.Fatalf("HITL mailbox description missing %q:\n%s", want, description)
|
|
57
59
|
}
|
|
58
60
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
t.Fatalf("agent mailbox description contains HITL Question instruction:\n%s", agentDescription)
|
|
61
|
+
for _, unwanted := range []string{"Jira", "OpenCode", "Question tool", "Approve and Reject", "Discuss the task with the human"} {
|
|
62
|
+
if strings.Contains(description, unwanted) {
|
|
63
|
+
t.Fatalf("generic mailbox description contains %q:\n%s", unwanted, description)
|
|
64
|
+
}
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
for _, taskSystem := range []string{"jira", "linear"} {
|
|
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\nBefore submitting your report, present the complete proposed report through OpenCode's built-in Question tool with exactly two options: Approve and Reject. Submit it only after an explicit Approve answer."
|
|
75
|
+
if prompt != want {
|
|
76
|
+
t.Fatalf("RenderPrompt(%s) = %q, want %q", taskSystem, prompt, want)
|
|
77
|
+
}
|
|
78
|
+
if strings.Contains(prompt, "Jira") || strings.Contains(prompt, "subtask") {
|
|
79
|
+
t.Fatalf("launch prompt contains task-system-specific mailbox wording: %q", prompt)
|
|
80
|
+
}
|
|
81
|
+
if strings.Contains(prompt, "STATUS:") || strings.Contains(prompt, node.Description) {
|
|
82
|
+
t.Fatalf("launch prompt duplicates mailbox instructions: %q", prompt)
|
|
70
83
|
}
|
|
71
|
-
}
|
|
72
|
-
if strings.Contains(prompt, "STATUS:") || strings.Contains(prompt, node.Description) {
|
|
73
|
-
t.Fatalf("launch prompt duplicates mailbox instructions: %q", prompt)
|
|
74
84
|
}
|
|
75
85
|
}
|
|
76
86
|
|
|
@@ -104,6 +114,50 @@ func newEngine(t *testing.T, deps goworkflows.Dependencies) *goworkflows.Engine
|
|
|
104
114
|
return e
|
|
105
115
|
}
|
|
106
116
|
|
|
117
|
+
func TestEnsureRunCreatesMissingWorkflowForExistingProjection(t *testing.T) {
|
|
118
|
+
log := newEventLog()
|
|
119
|
+
sys := newFakeTaskSystem(log)
|
|
120
|
+
path := filepath.Join(t.TempDir(), "state.db")
|
|
121
|
+
deps := goworkflows.Dependencies{
|
|
122
|
+
Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
|
|
123
|
+
}
|
|
124
|
+
engine, err := goworkflows.New(path, deps)
|
|
125
|
+
if err != nil {
|
|
126
|
+
t.Fatal(err)
|
|
127
|
+
}
|
|
128
|
+
wf := linearWorkflow(false)
|
|
129
|
+
rid := identity.NewRunID("payments", wf.Name, "PAY-101")
|
|
130
|
+
db, err := sql.Open("sqlite", path)
|
|
131
|
+
if err != nil {
|
|
132
|
+
t.Fatal(err)
|
|
133
|
+
}
|
|
134
|
+
if _, err := db.Exec(`INSERT INTO relay_runs
|
|
135
|
+
(id, repo, workflow, ticket_id, ticket_key, state, started_at, updated_at)
|
|
136
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, rid, "payments", wf.Name, "1", "PAY-101", run.StateStarting, time.Now(), time.Now()); err != nil {
|
|
137
|
+
t.Fatal(err)
|
|
138
|
+
}
|
|
139
|
+
db.Close()
|
|
140
|
+
if err := engine.Start(context.Background()); err != nil {
|
|
141
|
+
t.Fatal(err)
|
|
142
|
+
}
|
|
143
|
+
t.Cleanup(func() { _ = engine.Shutdown(context.Background()) })
|
|
144
|
+
|
|
145
|
+
created, err := engine.EnsureRun(context.Background(), run.Start{
|
|
146
|
+
ID: rid, Repo: "payments", RepoPath: "/srv/payments", Workflow: wf,
|
|
147
|
+
Ticket: task.TicketRef{ID: "1", Key: "PAY-101"},
|
|
148
|
+
})
|
|
149
|
+
if err != nil {
|
|
150
|
+
t.Fatal(err)
|
|
151
|
+
}
|
|
152
|
+
if !created {
|
|
153
|
+
t.Fatal("EnsureRun did not create the missing workflow instance")
|
|
154
|
+
}
|
|
155
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
156
|
+
r, err := engine.GetRun(context.Background(), rid)
|
|
157
|
+
return err == nil && r.CurrentNode == "coding"
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
107
161
|
func successReport(next string) workflow.Report {
|
|
108
162
|
none := "None"
|
|
109
163
|
return workflow.Report{
|
|
@@ -131,7 +185,11 @@ func TestRunBeginsAtStartAndFollowsEntryEdge(t *testing.T) {
|
|
|
131
185
|
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: fh,
|
|
132
186
|
})
|
|
133
187
|
|
|
134
|
-
|
|
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)
|
|
135
193
|
if err != nil {
|
|
136
194
|
t.Fatalf("EnsureRun failed: %v", err)
|
|
137
195
|
}
|
|
@@ -155,6 +213,17 @@ func TestRunBeginsAtStartAndFollowsEntryEdge(t *testing.T) {
|
|
|
155
213
|
if runtime.TerminalID == "" || runtime.NodeVisitID != r.CurrentNodeVisitID {
|
|
156
214
|
t.Fatalf("terminal was not persisted for current visit: %+v", runtime)
|
|
157
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
|
+
}
|
|
158
227
|
|
|
159
228
|
// Pre-edge gate: before following the start edge the run ensures the
|
|
160
229
|
// runner environment AND validates every referenced agent, and applies
|
|
@@ -233,6 +302,9 @@ func TestRevisitCreatesNewVisit(t *testing.T) {
|
|
|
233
302
|
r, _ := engine.GetRun(context.Background(), rid)
|
|
234
303
|
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
235
304
|
})
|
|
305
|
+
if indexOf(log.all(), "environmentStatus:in-progress") < 0 {
|
|
306
|
+
t.Fatalf("agent node did not set workspace status in-progress; events=%v", log.all())
|
|
307
|
+
}
|
|
236
308
|
|
|
237
309
|
r, _ := engine.GetRun(context.Background(), rid)
|
|
238
310
|
first := r.CurrentNodeVisitID
|
|
@@ -329,6 +401,9 @@ func TestEndCleanupDisabledKeepsRetainedRunner(t *testing.T) {
|
|
|
329
401
|
r, _ := engine.GetRun(context.Background(), rid)
|
|
330
402
|
return r.State == run.StateCompleted
|
|
331
403
|
})
|
|
404
|
+
if indexOf(log.all(), "environmentStatus:completed") < 0 {
|
|
405
|
+
t.Fatalf("end did not set workspace status completed; events=%v", log.all())
|
|
406
|
+
}
|
|
332
407
|
if len(fr.cleaned) != 0 || fr.liveTerminals() != 1 {
|
|
333
408
|
t.Fatalf("cleanup disabled: CleanupRun calls=%v live terminals=%d, want 0 and 1", fr.cleaned, fr.liveTerminals())
|
|
334
409
|
}
|
|
@@ -378,6 +453,10 @@ func TestTransitionOrdering(t *testing.T) {
|
|
|
378
453
|
r, _ := engine.GetRun(context.Background(), rid)
|
|
379
454
|
return r.CurrentNode == "review"
|
|
380
455
|
})
|
|
456
|
+
events := log.all()
|
|
457
|
+
if statusIdx, terminalIdx := indexOf(events, "environmentStatus:in-review"), indexOf(events, "ensureTerminal:PAY-101:review"); statusIdx < 0 || terminalIdx < 0 || statusIdx >= terminalIdx {
|
|
458
|
+
t.Fatalf("HITL status was not set before terminal start; events=%v", events)
|
|
459
|
+
}
|
|
381
460
|
|
|
382
461
|
// Exact cross-primitive order, observed through the fake-adapter and fake-
|
|
383
462
|
// runner call logs (the settled observation seam):
|
|
@@ -388,7 +467,6 @@ func TestTransitionOrdering(t *testing.T) {
|
|
|
388
467
|
// (recovery_test.go): a crash after acceptance, with comment injection
|
|
389
468
|
// failing, restarts on the same db and resumes the PERSISTED selected route
|
|
390
469
|
// without re-asking the agent or re-running effects.
|
|
391
|
-
events := log.all()
|
|
392
470
|
idx := map[string]int{}
|
|
393
471
|
for _, want := range []string{
|
|
394
472
|
"comment:PAY-101-coding", // summary to current mailbox
|
|
@@ -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
|
|
@@ -122,6 +123,22 @@ func (s *fakeTaskSystem) ValidateConfig(context.Context, config.RawValues, map[s
|
|
|
122
123
|
return nil
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
func (s *fakeTaskSystem) RenderText(kind task.TextKind, data task.TextData) (string, error) {
|
|
127
|
+
if s.renderText != nil {
|
|
128
|
+
return s.renderText(kind, data)
|
|
129
|
+
}
|
|
130
|
+
switch kind {
|
|
131
|
+
case task.TextMailboxDescription:
|
|
132
|
+
return "Parent ticket: " + data.Ticket + "\nNode: " + data.Node + "\nType: " + data.NodeType + "\nAgent: " + data.Agent + "\nWork: " + data.NodeDescription + "\nMailbox: " + data.Mailbox, nil
|
|
133
|
+
case task.TextSummaryComment:
|
|
134
|
+
return "SUMMARY\n" + data.SummaryReport, nil
|
|
135
|
+
case task.TextFeedbackComment:
|
|
136
|
+
return "Feedback from " + data.SourceNode + " to " + data.TargetNode + " mailbox " + data.Mailbox + "\n" + data.FeedbackReport, nil
|
|
137
|
+
default:
|
|
138
|
+
return "", fmt.Errorf("unknown task text kind %q", kind)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
125
142
|
func (s *fakeTaskSystem) EnsureMailboxes(_ context.Context, parent task.TicketRef, wf string, specs []task.MailboxSpec) (map[string]task.Mailbox, error) {
|
|
126
143
|
s.log.add("ensureMailboxes:" + parent.Key)
|
|
127
144
|
s.mu.Lock()
|
|
@@ -337,21 +354,17 @@ func (f *fakeRunner) EnsureEnvironment(_ context.Context, spec runner.RunSpec) (
|
|
|
337
354
|
return e, nil
|
|
338
355
|
}
|
|
339
356
|
|
|
340
|
-
func (f *fakeRunner)
|
|
341
|
-
f.
|
|
342
|
-
|
|
343
|
-
f.log.add("findTerminal:" + title)
|
|
344
|
-
ft, ok := f.terminals[env.ID+"/"+title]
|
|
345
|
-
if !ok || !ft.live {
|
|
346
|
-
return runner.Terminal{}, false, nil
|
|
347
|
-
}
|
|
348
|
-
return ft.term, true, nil
|
|
357
|
+
func (f *fakeRunner) SetEnvironmentStatus(_ context.Context, _ runner.Environment, status string) error {
|
|
358
|
+
f.log.add("environmentStatus:" + status)
|
|
359
|
+
return nil
|
|
349
360
|
}
|
|
350
361
|
|
|
351
|
-
func (f *fakeRunner)
|
|
362
|
+
func (f *fakeRunner) FindTerminal(_ context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
|
|
352
363
|
f.mu.Lock()
|
|
353
364
|
defer f.mu.Unlock()
|
|
354
|
-
|
|
365
|
+
if terminal.ID != "" {
|
|
366
|
+
f.log.add("findTerminalID:" + terminal.ID)
|
|
367
|
+
}
|
|
355
368
|
for _, ft := range f.terminals {
|
|
356
369
|
if ft.term.ID == terminal.ID && ft.live {
|
|
357
370
|
return ft.term, true, nil
|
|
@@ -381,17 +394,13 @@ func (f *fakeRunner) CreateTerminal(ctx context.Context, env runner.Environment,
|
|
|
381
394
|
return t, nil
|
|
382
395
|
}
|
|
383
396
|
|
|
384
|
-
func (f *fakeRunner) EnsureTerminal(
|
|
385
|
-
f.
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
return ft.term, nil
|
|
397
|
+
func (f *fakeRunner) EnsureTerminal(ctx context.Context, env runner.Environment, stored runner.Terminal, title string, command runner.Command) (runner.Terminal, error) {
|
|
398
|
+
if terminal, ok, err := f.FindTerminal(ctx, stored); err != nil {
|
|
399
|
+
return runner.Terminal{}, err
|
|
400
|
+
} else if ok {
|
|
401
|
+
return terminal, nil
|
|
390
402
|
}
|
|
391
|
-
|
|
392
|
-
f.terminals[key] = &fakeTerminal{term: t, live: true, title: title}
|
|
393
|
-
f.log.add("ensureTerminal:" + title)
|
|
394
|
-
return t, nil
|
|
403
|
+
return f.CreateTerminal(ctx, env, title, command)
|
|
395
404
|
}
|
|
396
405
|
|
|
397
406
|
func (f *fakeRunner) CloseTerminal(_ context.Context, t runner.Terminal) error {
|
|
@@ -463,16 +472,25 @@ func (f *fakeRunner) killTerminals() {
|
|
|
463
472
|
type fakeHarness struct {
|
|
464
473
|
log *eventLog
|
|
465
474
|
|
|
466
|
-
mu
|
|
467
|
-
validated
|
|
468
|
-
sessions
|
|
469
|
-
|
|
475
|
+
mu sync.Mutex
|
|
476
|
+
validated []string
|
|
477
|
+
sessions map[string]harness.Session
|
|
478
|
+
renderedPrompts []renderedPromptCall
|
|
479
|
+
reconcileNudge int // nudges sent to idle live HITL sessions (must stay 0)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
type renderedPromptCall struct {
|
|
483
|
+
Kind harness.PromptKind
|
|
484
|
+
Data harness.PromptData
|
|
485
|
+
NudgeTemplate string
|
|
470
486
|
}
|
|
471
487
|
|
|
472
488
|
func newFakeHarness(log *eventLog) *fakeHarness {
|
|
473
489
|
return &fakeHarness{log: log, sessions: map[string]harness.Session{}}
|
|
474
490
|
}
|
|
475
491
|
|
|
492
|
+
func (f *fakeHarness) SetupRepo(context.Context, string) error { return nil }
|
|
493
|
+
|
|
476
494
|
func (f *fakeHarness) ValidateAgent(_ context.Context, _, agent string) error {
|
|
477
495
|
f.mu.Lock()
|
|
478
496
|
f.validated = append(f.validated, agent)
|
|
@@ -489,6 +507,26 @@ func (f *fakeHarness) FindSession(_ context.Context, _, title string) (harness.S
|
|
|
489
507
|
return s, ok, nil
|
|
490
508
|
}
|
|
491
509
|
|
|
510
|
+
func (f *fakeHarness) RenderPrompt(kind harness.PromptKind, data harness.PromptData, nudge string) (string, error) {
|
|
511
|
+
f.mu.Lock()
|
|
512
|
+
f.renderedPrompts = append(f.renderedPrompts, renderedPromptCall{Kind: kind, Data: data, NudgeTemplate: nudge})
|
|
513
|
+
f.mu.Unlock()
|
|
514
|
+
prompt := string(kind) + ":" + data.TaskSystem + ":" + data.Mailbox
|
|
515
|
+
if data.NodeType == workflow.NodeHITL {
|
|
516
|
+
prompt += ":hitl"
|
|
517
|
+
}
|
|
518
|
+
if nudge != "" {
|
|
519
|
+
prompt += ":" + nudge
|
|
520
|
+
}
|
|
521
|
+
return prompt, nil
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
func (f *fakeHarness) promptCalls() []renderedPromptCall {
|
|
525
|
+
f.mu.Lock()
|
|
526
|
+
defer f.mu.Unlock()
|
|
527
|
+
return append([]renderedPromptCall(nil), f.renderedPrompts...)
|
|
528
|
+
}
|
|
529
|
+
|
|
492
530
|
func (f *fakeHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
|
|
493
531
|
f.log.add("buildCommand:" + spec.Node + ":" + string(spec.NodeVisitID) + ":resume=" + spec.ResumeID)
|
|
494
532
|
return runner.Command{Executable: "opencode"}, nil
|
|
@@ -188,21 +188,9 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
|
|
|
188
188
|
|
|
189
189
|
title := start.Ticket.Key + ":" + current
|
|
190
190
|
|
|
191
|
-
// Build
|
|
192
|
-
//
|
|
193
|
-
// complete report contract, and valid next steps. Custom instructions
|
|
194
|
-
// are also retained separately for live-terminal revisits.
|
|
191
|
+
// Build task-system-neutral prompt data from the workflow snapshot. The
|
|
192
|
+
// selected harness owns rendering initial, feedback, and HITL text.
|
|
195
193
|
nextSteps := append(append([]workflow.Route{}, node.OnSuccess...), node.OnFailure...)
|
|
196
|
-
nudge, err := wf.RenderNudge(current, workflow.NudgeTemplateData{
|
|
197
|
-
Ticket: start.Ticket.Key,
|
|
198
|
-
Workflow: wf.Name,
|
|
199
|
-
Repo: start.Repo,
|
|
200
|
-
Node: current,
|
|
201
|
-
NextSteps: nextStepsText(nextSteps),
|
|
202
|
-
})
|
|
203
|
-
if err != nil {
|
|
204
|
-
return err
|
|
205
|
-
}
|
|
206
194
|
spec := harness.LaunchSpec{
|
|
207
195
|
RunID: start.ID,
|
|
208
196
|
NodeVisitID: visitID,
|
|
@@ -214,9 +202,20 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
|
|
|
214
202
|
NodeType: node.Type,
|
|
215
203
|
Agent: node.Agent,
|
|
216
204
|
Title: title,
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
205
|
+
NudgePrompt: node.NudgePrompt,
|
|
206
|
+
PromptData: harness.PromptData{
|
|
207
|
+
TaskSystem: a.TaskSystem,
|
|
208
|
+
Ticket: start.Ticket.Key,
|
|
209
|
+
Workflow: wf.Name,
|
|
210
|
+
Repo: start.Repo,
|
|
211
|
+
Node: current,
|
|
212
|
+
NodeType: node.Type,
|
|
213
|
+
Agent: node.Agent,
|
|
214
|
+
NodeDescription: node.Description,
|
|
215
|
+
NextSteps: nextStepsText(nextSteps),
|
|
216
|
+
Mailbox: mb.Key,
|
|
217
|
+
},
|
|
218
|
+
NextSteps: nextSteps,
|
|
220
219
|
}
|
|
221
220
|
if runtime.SessionID != "" {
|
|
222
221
|
spec.ResumeID = runtime.SessionID
|
|
@@ -304,10 +303,14 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
|
|
|
304
303
|
// complete current -> process next node.
|
|
305
304
|
if _, err := retryLoop(ctx, start.ID, a, work, current,
|
|
306
305
|
func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
|
|
306
|
+
summaryReport := renderSummaryReport(report)
|
|
307
307
|
return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.Comment, start.Repo, run.CommentWork{
|
|
308
|
-
RunID:
|
|
309
|
-
|
|
310
|
-
|
|
308
|
+
RunID: start.ID, Item: task.Target{Parent: work.Parent, Mailbox: &mb},
|
|
309
|
+
TextKind: task.TextSummaryComment,
|
|
310
|
+
TextData: task.TextData{RunID: string(start.ID), Ticket: work.Parent.Key,
|
|
311
|
+
Workflow: wf.Name, Repo: start.Repo, Node: current, NodeType: string(node.Type),
|
|
312
|
+
Agent: node.Agent, NodeDescription: node.Description, Mailbox: mb.Key,
|
|
313
|
+
SourceNode: current, TargetNode: current, SummaryReport: summaryReport},
|
|
311
314
|
Marker: string(visitID) + ":summary",
|
|
312
315
|
})
|
|
313
316
|
}); err != nil {
|
|
@@ -319,10 +322,15 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
|
|
|
319
322
|
nextMb := mailboxes[next]
|
|
320
323
|
if _, err := retryLoop(ctx, start.ID, a, work, current,
|
|
321
324
|
func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
|
|
325
|
+
feedbackReport := renderFeedbackReport(report)
|
|
322
326
|
return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.Comment, start.Repo, run.CommentWork{
|
|
323
|
-
RunID:
|
|
324
|
-
|
|
325
|
-
|
|
327
|
+
RunID: start.ID, Item: task.Target{Parent: work.Parent, Mailbox: &nextMb},
|
|
328
|
+
TextKind: task.TextFeedbackComment,
|
|
329
|
+
TextData: task.TextData{RunID: string(start.ID), Ticket: work.Parent.Key,
|
|
330
|
+
Workflow: wf.Name, Repo: start.Repo, Node: next, NodeType: string(wf.Nodes[next].Type),
|
|
331
|
+
Agent: wf.Nodes[next].Agent, NodeDescription: wf.Nodes[next].Description, Mailbox: nextMb.Key,
|
|
332
|
+
SourceNode: current, TargetNode: next, SummaryReport: renderSummaryReport(report),
|
|
333
|
+
FeedbackReport: feedbackReport},
|
|
326
334
|
Marker: string(visitID) + ":feedback",
|
|
327
335
|
})
|
|
328
336
|
}); err != nil {
|
|
@@ -360,6 +368,13 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
|
|
|
360
368
|
}); err != nil {
|
|
361
369
|
return err
|
|
362
370
|
}
|
|
371
|
+
if _, err := retryLoop(ctx, start.ID, a, work, "end",
|
|
372
|
+
func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
|
|
373
|
+
return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries,
|
|
374
|
+
a.SetEnvironmentStatus, work, start.RepoPath, runner.WorkspaceStatusCompleted)
|
|
375
|
+
}); err != nil {
|
|
376
|
+
return err
|
|
377
|
+
}
|
|
363
378
|
finalPolicy := work.Runtime
|
|
364
379
|
if wf.CleanupRunnerOnEnd {
|
|
365
380
|
finalPolicy.KeepTerminalsAlive = false
|
|
@@ -401,7 +416,7 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
|
|
|
401
416
|
// run-level activities like EnsureMailboxes / start/end ApplyTaskConfig).
|
|
402
417
|
// The log is wrapped in a replay-safe SideEffect so replays never re-emit
|
|
403
418
|
// it, and the error message is sanitized so info logs never embed argv
|
|
404
|
-
// payloads (
|
|
419
|
+
// payloads (Jira/Orca request bodies, commands, JQL, and prompt strings).
|
|
405
420
|
func retryLoop[T any](ctx goworkflow.Context, id run.ID, a *Activities, work run.Work, node string, schedule func(goworkflow.Context) goworkflow.Future[T]) (T, error) {
|
|
406
421
|
var zero T
|
|
407
422
|
attempt := 0
|
|
@@ -475,7 +490,7 @@ func logRetry(ctx goworkflow.Context, work run.Work, node string, f retry.Failur
|
|
|
475
490
|
}
|
|
476
491
|
|
|
477
492
|
// sanitizeRetryMessage strips each "[...]: " argv-listing span that adapter
|
|
478
|
-
// wrappers embed
|
|
493
|
+
// wrappers may embed argv/request context, so the info-level retry
|
|
479
494
|
// record never leaks --body/--command/JQL/prompt payloads. Keeps the
|
|
480
495
|
// surrounding wrap context and trailing exit status / stderr fragment that
|
|
481
496
|
// carry the failure reason. The original error returned to callers is
|
|
@@ -594,12 +609,12 @@ func nextStepsText(routes []workflow.Route) string {
|
|
|
594
609
|
return b.String()
|
|
595
610
|
}
|
|
596
611
|
|
|
597
|
-
func
|
|
598
|
-
return fmt.Sprintf("
|
|
612
|
+
func renderSummaryReport(r workflow.Report) string {
|
|
613
|
+
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",
|
|
599
614
|
r.Summary.Completed, r.Summary.Commits, r.Summary.NotCompleted, r.Summary.IssuesDiscovered, r.Summary.Verification, r.Summary.Notes)
|
|
600
615
|
}
|
|
601
616
|
|
|
602
|
-
func
|
|
603
|
-
return fmt.Sprintf("
|
|
604
|
-
|
|
617
|
+
func renderFeedbackReport(r workflow.Report) string {
|
|
618
|
+
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",
|
|
619
|
+
r.Summary.Commits, r.Feedback.ReasonForNextStep, r.Feedback.RequiredActions, r.Feedback.RelevantContext, r.Feedback.ExpectedResult)
|
|
605
620
|
}
|
|
@@ -266,6 +266,91 @@ func TestSummaryCurrentFeedbackSelectedNextOnly(t *testing.T) {
|
|
|
266
266
|
}
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
func TestTaskSystemTemplatesRenderMailboxAndSplitOneReport(t *testing.T) {
|
|
270
|
+
log := newEventLog()
|
|
271
|
+
sys := newFakeTaskSystem(log)
|
|
272
|
+
seen := map[task.TextKind][]task.TextData{}
|
|
273
|
+
sys.renderText = func(kind task.TextKind, data task.TextData) (string, error) {
|
|
274
|
+
seen[kind] = append(seen[kind], data)
|
|
275
|
+
switch kind {
|
|
276
|
+
case task.TextMailboxDescription:
|
|
277
|
+
return "custom mailbox " + data.Node + " work=" + data.NodeDescription, nil
|
|
278
|
+
case task.TextSummaryComment:
|
|
279
|
+
return "custom summary node=" + data.Node + " mailbox=" + data.Mailbox + "\n" + data.SummaryReport, nil
|
|
280
|
+
case task.TextFeedbackComment:
|
|
281
|
+
return "custom feedback source=" + data.SourceNode + " target=" + data.TargetNode + " mailbox=" + data.Mailbox + "\n" + data.FeedbackReport, nil
|
|
282
|
+
default:
|
|
283
|
+
return "", nil
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
287
|
+
Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log), TaskSystem: "custom-task",
|
|
288
|
+
})
|
|
289
|
+
rid, _ := startRun(engine, threeNodeWorkflow())
|
|
290
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
291
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
292
|
+
return r.CurrentNode == "exploration"
|
|
293
|
+
})
|
|
294
|
+
if got := sys.specs[1].Description; !strings.Contains(got, "custom mailbox exploration work=explore the code") || !strings.Contains(got, "Required report format:") {
|
|
295
|
+
t.Fatalf("rendered mailbox description = %q", got)
|
|
296
|
+
}
|
|
297
|
+
var explorationData task.TextData
|
|
298
|
+
for _, data := range seen[task.TextMailboxDescription] {
|
|
299
|
+
if data.Node == "exploration" {
|
|
300
|
+
explorationData = data
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
for name, got := range map[string]string{
|
|
304
|
+
"runID": explorationData.RunID,
|
|
305
|
+
"ticket": explorationData.Ticket, "workflow": explorationData.Workflow,
|
|
306
|
+
"repo": explorationData.Repo, "node": explorationData.Node,
|
|
307
|
+
"nodeType": explorationData.NodeType, "agent": explorationData.Agent,
|
|
308
|
+
"nodeDescription": explorationData.NodeDescription, "mailbox": explorationData.Mailbox,
|
|
309
|
+
} {
|
|
310
|
+
if got == "" {
|
|
311
|
+
t.Fatalf("mailbox template value %s was empty: %+v", name, explorationData)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
for name, got := range map[string]string{"nextSteps": explorationData.NextSteps, "successRoutes": explorationData.SuccessRoutes, "failureRoutes": explorationData.FailureRoutes} {
|
|
315
|
+
if !strings.Contains(got, "coding") && name != "failureRoutes" {
|
|
316
|
+
t.Fatalf("mailbox template %s = %q", name, got)
|
|
317
|
+
}
|
|
318
|
+
if name == "failureRoutes" && !strings.Contains(got, "exploration") {
|
|
319
|
+
t.Fatalf("mailbox template failureRoutes = %q", got)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
report := successReport("coding")
|
|
323
|
+
report.Feedback = workflow.Feedback{ReasonForNextStep: "reviewed", RequiredActions: "implement", RelevantContext: "ctx", ExpectedResult: "done"}
|
|
324
|
+
if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "exploration", report)); err != nil {
|
|
325
|
+
t.Fatal(err)
|
|
326
|
+
}
|
|
327
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
328
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
329
|
+
return r.CurrentNode == "coding"
|
|
330
|
+
})
|
|
331
|
+
summary := sys.commentBodies("PAY-101-exploration")
|
|
332
|
+
feedback := sys.commentBodies("PAY-101-coding")
|
|
333
|
+
if len(summary) != 1 || !strings.Contains(summary[0].Body, "custom summary node=exploration mailbox=PAY-101-exploration") || !strings.Contains(summary[0].Body, "COMPLETED:") {
|
|
334
|
+
t.Fatalf("summary comments = %+v", summary)
|
|
335
|
+
}
|
|
336
|
+
if len(feedback) != 1 || !strings.Contains(feedback[0].Body, "source=exploration target=coding mailbox=PAY-101-coding") || !strings.Contains(feedback[0].Body, "REQUIRED ACTIONS:") {
|
|
337
|
+
t.Fatalf("feedback comments = %+v", feedback)
|
|
338
|
+
}
|
|
339
|
+
if len(seen[task.TextSummaryComment]) != 1 || seen[task.TextSummaryComment][0].SummaryReport == "" {
|
|
340
|
+
t.Fatalf("summary template data = %+v", seen[task.TextSummaryComment])
|
|
341
|
+
}
|
|
342
|
+
if len(seen[task.TextFeedbackComment]) != 1 {
|
|
343
|
+
t.Fatalf("feedback template data = %+v", seen[task.TextFeedbackComment])
|
|
344
|
+
}
|
|
345
|
+
feedbackData := seen[task.TextFeedbackComment][0]
|
|
346
|
+
if feedbackData.SourceNode != "exploration" || feedbackData.TargetNode != "coding" || feedbackData.Mailbox != "PAY-101-coding" || feedbackData.FeedbackReport == "" {
|
|
347
|
+
t.Fatalf("feedback template data = %+v", feedbackData)
|
|
348
|
+
}
|
|
349
|
+
if len(sys.commentBodies("PAY-101-review")) != 0 {
|
|
350
|
+
t.Fatal("one report sent feedback to an unselected mailbox")
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
269
354
|
// 3.28: end/mailbox behavior, manual status not routing, HITL lifecycle.
|
|
270
355
|
|
|
271
356
|
func TestManualMailboxStatusDoesNotRouteGraph(t *testing.T) {
|
|
@@ -84,8 +84,8 @@ func TestPersistedRuntimeLoopAndRestart(t *testing.T) {
|
|
|
84
84
|
if err != nil || !staleAck.Duplicate {
|
|
85
85
|
t.Fatalf("stale ack=%+v err=%v", staleAck, err)
|
|
86
86
|
}
|
|
87
|
-
if log.count("
|
|
88
|
-
t.Fatalf("normal path
|
|
87
|
+
if log.count("findTerminalID:") == 0 {
|
|
88
|
+
t.Fatalf("normal path did not check persisted terminal IDs: %v", log.all())
|
|
89
89
|
}
|
|
90
90
|
if log.count("findSession:") != 0 {
|
|
91
91
|
t.Fatalf("normal path used session discovery: %v", log.all())
|
|
@@ -93,7 +93,7 @@ func TestPersistedRuntimeLoopAndRestart(t *testing.T) {
|
|
|
93
93
|
|
|
94
94
|
oldTerminal := secondRT.TerminalID
|
|
95
95
|
fr.killTerminals()
|
|
96
|
-
findTerminalBefore := log.count("
|
|
96
|
+
findTerminalBefore := log.count("findTerminalID:")
|
|
97
97
|
findSessionBefore := log.count("findSession:")
|
|
98
98
|
e2 := restartEngine(t, db, deps, e1)
|
|
99
99
|
waitFor(t, 10*time.Second, func() bool {
|
|
@@ -104,13 +104,19 @@ func TestPersistedRuntimeLoopAndRestart(t *testing.T) {
|
|
|
104
104
|
t.Fatal(err)
|
|
105
105
|
}
|
|
106
106
|
resumeEvent := "buildCommand:implement:" + string(second.CurrentNodeVisitID) + ":resume=session-implement"
|
|
107
|
-
waitFor(t, 10*time.Second, func() bool {
|
|
107
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
108
|
+
runtime, _ := e2.GetNodeRuntime(context.Background(), rid, "implement")
|
|
109
|
+
return runtime.TerminalID != "" && runtime.TerminalID != oldTerminal
|
|
110
|
+
})
|
|
108
111
|
after, _ := e2.GetNodeRuntime(context.Background(), rid, "implement")
|
|
109
112
|
if after.TerminalID == oldTerminal || after.SessionID != "session-implement" || after.NodeVisitID != second.CurrentNodeVisitID {
|
|
110
113
|
t.Fatalf("restart did not resume/replace direct runtime: old=%+v after=%+v", secondRT, after)
|
|
111
114
|
}
|
|
112
|
-
if log.count("
|
|
113
|
-
t.Fatalf("restart
|
|
115
|
+
if log.count("findTerminalID:") == findTerminalBefore || log.count("findSession:") != findSessionBefore {
|
|
116
|
+
t.Fatalf("restart did not use only the persisted terminal/session IDs: %v", log.all())
|
|
117
|
+
}
|
|
118
|
+
if log.count(resumeEvent) < 1 {
|
|
119
|
+
t.Fatalf("restart did not pass persisted session ID to harness: %v", log.all())
|
|
114
120
|
}
|
|
115
121
|
}
|
|
116
122
|
|