relay-flow 0.0.1 → 0.2.0-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 +148 -143
- package/cmd/relay-flow/commands_test.go +464 -0
- package/cmd/relay-flow/main.go +670 -180
- package/cmd/relay-flow/scenario_test.go +1135 -0
- package/cmd/relay-flow/serve.go +609 -0
- package/go.mod +69 -2
- package/go.sum +185 -0
- package/internal/config/config.go +88 -0
- package/internal/config/machine.go +99 -48
- package/internal/config/machine_test.go +248 -0
- package/internal/config/merge_test.go +118 -0
- package/internal/config/writeatomic.go +36 -0
- package/internal/config/writeatomic_test.go +98 -0
- package/internal/execution/goworkflows/activities.go +490 -0
- package/internal/execution/goworkflows/engine.go +487 -0
- package/internal/execution/goworkflows/engine_test.go +600 -0
- package/internal/execution/goworkflows/fakes_test.go +517 -0
- package/internal/execution/goworkflows/interpreter.go +605 -0
- package/internal/execution/goworkflows/logging_test.go +154 -0
- package/internal/execution/goworkflows/mailbox_test.go +423 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
- package/internal/execution/goworkflows/node_runtime_test.go +486 -0
- package/internal/execution/goworkflows/projection.go +504 -0
- package/internal/execution/goworkflows/recovery_test.go +1092 -0
- package/internal/execution/goworkflows/retry_log_test.go +59 -0
- package/internal/execution/goworkflows/retry_projection_test.go +98 -0
- package/internal/harness/contract_test.go +169 -0
- package/internal/harness/factory.go +63 -0
- package/internal/harness/harness.go +41 -0
- package/internal/harness/opencode/opencode.go +166 -0
- package/internal/harness/opencode/opencode_test.go +50 -0
- package/internal/harness/plugin_selection_test.go +126 -0
- package/internal/identity/identity.go +37 -0
- package/internal/logging/logging.go +56 -0
- package/internal/logging/logging_test.go +116 -0
- package/internal/paths/paths.go +67 -0
- package/internal/recover/recover.go +115 -0
- package/internal/repo/poller.go +186 -0
- package/internal/repo/poller_test.go +327 -0
- package/internal/repo/repo.go +119 -0
- package/internal/repo/service.go +216 -0
- package/internal/repo/service_test.go +298 -0
- package/internal/retry/retry.go +118 -0
- package/internal/router/router.go +83 -0
- package/internal/router/router_test.go +144 -0
- package/internal/run/manager.go +108 -0
- package/internal/run/run.go +140 -0
- package/internal/run/run_identity_test.go +52 -0
- package/internal/run/run_manager_test.go +266 -0
- package/internal/runner/contract_test.go +221 -0
- package/internal/runner/factory.go +65 -0
- package/internal/runner/orca/orca.go +363 -170
- package/internal/runner/orca/orca_test.go +134 -160
- package/internal/runner/orca/orcacli/orcacli.go +215 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
- package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
- package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
- package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
- package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
- package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
- package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
- package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
- package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
- package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
- package/internal/runner/runner.go +47 -64
- package/internal/server/api_test.go +300 -0
- package/internal/server/client.go +192 -74
- package/internal/server/fixture_test.go +248 -0
- package/internal/server/server.go +425 -248
- package/internal/server/shutdown_test.go +116 -0
- package/internal/task/contract_test.go +223 -0
- package/internal/task/factory.go +103 -0
- package/internal/task/jira/acli/acli.go +306 -0
- package/internal/task/jira/acli/acli_test.go +208 -0
- package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
- package/internal/task/jira/acli/testdata/search_success.json +1 -0
- package/internal/task/jira/filters_test.go +234 -0
- package/internal/task/jira/helpers_test.go +60 -0
- package/internal/task/jira/jira.go +507 -0
- package/internal/task/jira/normalize.go +101 -0
- package/internal/task/jira/testdata/acli_search.json +120 -0
- package/internal/task/jira/transition_defaults_test.go +156 -0
- package/internal/task/jira/validation_test.go +94 -0
- package/internal/task/task.go +84 -0
- package/internal/workflow/report.go +85 -0
- package/internal/workflow/report_test.go +259 -0
- package/internal/workflow/service.go +142 -0
- package/internal/workflow/store.go +136 -0
- package/internal/workflow/store_test.go +282 -0
- package/internal/workflow/workflow.go +342 -0
- package/internal/workflow/workflow_test.go +410 -0
- package/package.json +1 -1
- package/internal/acli/acli.go +0 -229
- package/internal/config/demo_test.go +0 -17
- package/internal/config/schema.go +0 -193
- package/internal/config/schema_test.go +0 -162
- package/internal/daemon/daemon.go +0 -218
- package/internal/daemon/daemon_test.go +0 -204
- package/internal/discovery/discovery.go +0 -122
- package/internal/discovery/discovery_test.go +0 -62
- package/internal/opencode/opencode.go +0 -26
- package/internal/orcacli/orcacli.go +0 -264
- package/internal/runner/orca/README.md +0 -64
- package/internal/runner/runner_test.go +0 -64
- package/internal/server/server_test.go +0 -195
- package/internal/tasks/jira/README.md +0 -69
- package/internal/tasks/jira/component_test.go +0 -16
- package/internal/tasks/jira/decode.go +0 -24
- package/internal/tasks/jira/jira.go +0 -231
- package/internal/tasks/jira/jira_test.go +0 -259
- package/internal/tasks/jira/jql_test.go +0 -16
- package/internal/tasks/tasks.go +0 -90
- package/internal/tasks/tasks_test.go +0 -91
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
package goworkflows_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/identity"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
14
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
15
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
// 3.14-3.16, 3.20, 3.22, 3.26: durable run execution behavior per
|
|
19
|
+
// specs/durable-run-execution. Fakes live in fakes_test.go and record one
|
|
20
|
+
// ordered event log so ordering and no-replay claims are observable.
|
|
21
|
+
|
|
22
|
+
func linearWorkflow(cleanup bool) workflow.Workflow {
|
|
23
|
+
return workflow.Workflow{
|
|
24
|
+
Name: "basicFlow",
|
|
25
|
+
Repos: []string{"payments"},
|
|
26
|
+
CleanupRunnerOnEnd: cleanup,
|
|
27
|
+
TaskConfig: map[string]any{
|
|
28
|
+
"transitionTo": map[string]any{"parentStatus": "In Progress"},
|
|
29
|
+
},
|
|
30
|
+
Nodes: map[string]workflow.Node{
|
|
31
|
+
"start": {OnSuccess: []workflow.Route{{Target: "coding"}}},
|
|
32
|
+
"coding": {
|
|
33
|
+
Type: workflow.NodeAgent, Agent: "build", Description: "work",
|
|
34
|
+
OnSuccess: []workflow.Route{{Target: "end"}},
|
|
35
|
+
OnFailure: []workflow.Route{{Target: "coding"}},
|
|
36
|
+
},
|
|
37
|
+
"end": {TaskConfig: map[string]any{"transitionTo": map[string]any{"parentStatus": "Done"}}},
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func TestMailboxDescriptionRequiresQuestionForHITL(t *testing.T) {
|
|
43
|
+
wf := linearWorkflow(false)
|
|
44
|
+
node := wf.Nodes["coding"]
|
|
45
|
+
node.Type = workflow.NodeHITL
|
|
46
|
+
description := goworkflows.MailboxSpecForNode(&wf, "PAY-101", "coding", node).Description
|
|
47
|
+
for _, want := range []string{
|
|
48
|
+
"Do not make code changes",
|
|
49
|
+
"until the human is satisfied with the review",
|
|
50
|
+
"OpenCode's Question tool",
|
|
51
|
+
"Approve and Reject",
|
|
52
|
+
"If approved, output the report verbatim",
|
|
53
|
+
"If rejected, return to step 1",
|
|
54
|
+
} {
|
|
55
|
+
if !strings.Contains(description, want) {
|
|
56
|
+
t.Fatalf("HITL mailbox description missing %q:\n%s", want, description)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
node.Type = workflow.NodeAgent
|
|
61
|
+
agentDescription := goworkflows.MailboxSpecForNode(&wf, "PAY-101", "coding", node).Description
|
|
62
|
+
if strings.Contains(agentDescription, "Question tool") {
|
|
63
|
+
t.Fatalf("agent mailbox description contains HITL Question instruction:\n%s", agentDescription)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
prompt := goworkflows.BuildLaunchSpecPrompt("PAY-101", "PAY-234")
|
|
67
|
+
for _, want := range []string{"parent Jira ticket PAY-101", "mailbox subtask is PAY-234", "description and comments"} {
|
|
68
|
+
if !strings.Contains(prompt, want) {
|
|
69
|
+
t.Fatalf("compact launch prompt missing %q: %q", want, prompt)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if strings.Contains(prompt, "STATUS:") || strings.Contains(prompt, node.Description) {
|
|
73
|
+
t.Fatalf("launch prompt duplicates mailbox instructions: %q", prompt)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func startRun(engine *goworkflows.Engine, wf workflow.Workflow) (run.ID, error) {
|
|
78
|
+
rid := identity.NewRunID("payments", wf.Name, "PAY-101")
|
|
79
|
+
_, err := engine.EnsureRun(context.Background(), run.Start{
|
|
80
|
+
ID: rid,
|
|
81
|
+
Repo: "payments",
|
|
82
|
+
RepoPath: "/srv/payments",
|
|
83
|
+
Workflow: wf,
|
|
84
|
+
Ticket: task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"},
|
|
85
|
+
})
|
|
86
|
+
return rid, err
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
func newEngine(t *testing.T, deps goworkflows.Dependencies) *goworkflows.Engine {
|
|
90
|
+
t.Helper()
|
|
91
|
+
path := filepath.Join(t.TempDir(), "state.db")
|
|
92
|
+
e, err := goworkflows.New(path, deps)
|
|
93
|
+
if err != nil {
|
|
94
|
+
t.Fatalf("goworkflows.New failed: %v", err)
|
|
95
|
+
}
|
|
96
|
+
if err := e.Start(context.Background()); err != nil {
|
|
97
|
+
t.Fatalf("engine.Start failed: %v", err)
|
|
98
|
+
}
|
|
99
|
+
t.Cleanup(func() {
|
|
100
|
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
101
|
+
defer cancel()
|
|
102
|
+
_ = e.Shutdown(ctx)
|
|
103
|
+
})
|
|
104
|
+
return e
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func successReport(next string) workflow.Report {
|
|
108
|
+
none := "None"
|
|
109
|
+
return workflow.Report{
|
|
110
|
+
Status: workflow.OutcomeSuccess,
|
|
111
|
+
NextStep: next,
|
|
112
|
+
Summary: workflow.Summary{
|
|
113
|
+
Completed: "done", Commits: "abc123", NotCompleted: none, IssuesDiscovered: none,
|
|
114
|
+
Verification: "tested", Notes: none,
|
|
115
|
+
},
|
|
116
|
+
Feedback: workflow.Feedback{
|
|
117
|
+
ReasonForNextStep: none, RequiredActions: none,
|
|
118
|
+
RelevantContext: none, ExpectedResult: none,
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- 3.15: serial graph ---
|
|
124
|
+
|
|
125
|
+
func TestRunBeginsAtStartAndFollowsEntryEdge(t *testing.T) {
|
|
126
|
+
log := newEventLog()
|
|
127
|
+
sys := newFakeTaskSystem(log)
|
|
128
|
+
fr := newFakeRunner(log)
|
|
129
|
+
fh := newFakeHarness(log)
|
|
130
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
131
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: fh,
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
rid, err := startRun(engine, linearWorkflow(false))
|
|
135
|
+
if err != nil {
|
|
136
|
+
t.Fatalf("EnsureRun failed: %v", err)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
140
|
+
r, err := engine.GetRun(context.Background(), rid)
|
|
141
|
+
return err == nil && r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
145
|
+
if r.State != run.StateWaiting && r.State != run.StateRunning {
|
|
146
|
+
t.Fatalf("state = %q, want waiting/running at coding", r.State)
|
|
147
|
+
}
|
|
148
|
+
if len(fr.envs) != 1 {
|
|
149
|
+
t.Fatalf("runner environments = %d, want exactly 1 ticket-scoped env", len(fr.envs))
|
|
150
|
+
}
|
|
151
|
+
runtime, err := engine.GetNodeRuntime(context.Background(), rid, "coding")
|
|
152
|
+
if err != nil {
|
|
153
|
+
t.Fatalf("GetNodeRuntime: %v", err)
|
|
154
|
+
}
|
|
155
|
+
if runtime.TerminalID == "" || runtime.NodeVisitID != r.CurrentNodeVisitID {
|
|
156
|
+
t.Fatalf("terminal was not persisted for current visit: %+v", runtime)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Pre-edge gate: before following the start edge the run ensures the
|
|
160
|
+
// runner environment AND validates every referenced agent, and applies
|
|
161
|
+
// the start taskConfig. Assert all three happened before the coding
|
|
162
|
+
// terminal was started.
|
|
163
|
+
events := log.all()
|
|
164
|
+
envIdx, validateIdx, applyIdx, terminalIdx := -1, -1, -1, -1
|
|
165
|
+
for i, e := range events {
|
|
166
|
+
switch {
|
|
167
|
+
case envIdx < 0 && hasPrefix(e, "ensureEnvironment:"):
|
|
168
|
+
envIdx = i
|
|
169
|
+
case validateIdx < 0 && hasPrefix(e, "validateAgent:build"):
|
|
170
|
+
validateIdx = i
|
|
171
|
+
case applyIdx < 0 && hasPrefix(e, "applyTaskConfig:"):
|
|
172
|
+
applyIdx = i
|
|
173
|
+
case terminalIdx < 0 && hasPrefix(e, "ensureTerminal:PAY-101:coding"):
|
|
174
|
+
terminalIdx = i
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if envIdx < 0 {
|
|
178
|
+
t.Fatal("runner environment never ensured before start edge")
|
|
179
|
+
}
|
|
180
|
+
if validateIdx < 0 {
|
|
181
|
+
t.Fatal("referenced agent never validated before start edge")
|
|
182
|
+
}
|
|
183
|
+
if applyIdx < 0 {
|
|
184
|
+
t.Fatal("start taskConfig never applied")
|
|
185
|
+
}
|
|
186
|
+
if terminalIdx < 0 {
|
|
187
|
+
t.Fatal("coding terminal never started")
|
|
188
|
+
}
|
|
189
|
+
if !(envIdx < terminalIdx && validateIdx < terminalIdx && applyIdx < terminalIdx) {
|
|
190
|
+
t.Fatalf("pre-edge gate violated; events=%v", events)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
func TestSerialGraphOneNodeAtATime(t *testing.T) {
|
|
195
|
+
log := newEventLog()
|
|
196
|
+
sys := newFakeTaskSystem(log)
|
|
197
|
+
fr := newFakeRunner(log)
|
|
198
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
199
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
rid, err := startRun(engine, linearWorkflow(false))
|
|
203
|
+
if err != nil {
|
|
204
|
+
t.Fatal(err)
|
|
205
|
+
}
|
|
206
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
207
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
208
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
ack, err := engine.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end")))
|
|
212
|
+
if err != nil {
|
|
213
|
+
t.Fatalf("SubmitReport failed: %v", err)
|
|
214
|
+
}
|
|
215
|
+
if !ack.Accepted {
|
|
216
|
+
t.Fatalf("ack = %+v, want accepted", ack)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
220
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
221
|
+
return r.State == run.StateCompleted
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
func TestRevisitCreatesNewVisit(t *testing.T) {
|
|
226
|
+
log := newEventLog()
|
|
227
|
+
sys := newFakeTaskSystem(log)
|
|
228
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
229
|
+
Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
|
|
230
|
+
})
|
|
231
|
+
rid, _ := startRun(engine, linearWorkflow(false))
|
|
232
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
233
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
234
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
238
|
+
first := r.CurrentNodeVisitID
|
|
239
|
+
|
|
240
|
+
fail := successReport("coding")
|
|
241
|
+
fail.Status = workflow.OutcomeFailure
|
|
242
|
+
if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "coding", fail)); err != nil {
|
|
243
|
+
t.Fatal(err)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
247
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
248
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != "" && r.CurrentNodeVisitID != first
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
func TestEndAppliesConfigAndCompletes(t *testing.T) {
|
|
253
|
+
log := newEventLog()
|
|
254
|
+
sys := newFakeTaskSystem(log)
|
|
255
|
+
fr := newFakeRunner(log)
|
|
256
|
+
wf := linearWorkflow(true) // cleanupRunnerOnEnd
|
|
257
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
258
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
|
|
259
|
+
Runtime: &run.RuntimePolicy{KeepTerminalsAlive: true, KeepSessionsAlive: true},
|
|
260
|
+
})
|
|
261
|
+
rid, _ := startRun(engine, wf)
|
|
262
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
263
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
264
|
+
return r.CurrentNode == "coding"
|
|
265
|
+
})
|
|
266
|
+
if _, err := engine.RegisterNodeSession(context.Background(), run.NodeRuntimeRegistration{
|
|
267
|
+
RunID: rid, Node: "coding", SessionID: "session-coding",
|
|
268
|
+
}); err != nil {
|
|
269
|
+
t.Fatal(err)
|
|
270
|
+
}
|
|
271
|
+
if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end"))); err != nil {
|
|
272
|
+
t.Fatal(err)
|
|
273
|
+
}
|
|
274
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
275
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
276
|
+
return r.State == run.StateCompleted
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
// end taskConfig applied (parent Done) before runner cleanup.
|
|
280
|
+
events := log.all()
|
|
281
|
+
endApplyIdx, cleanupIdx := -1, -1
|
|
282
|
+
for i, e := range events {
|
|
283
|
+
if endApplyIdx < 0 && e == "applyTaskConfig:PAY-101" && i > 0 {
|
|
284
|
+
// the second parent application is the end config (start applied first)
|
|
285
|
+
endApplyIdx = i
|
|
286
|
+
}
|
|
287
|
+
if cleanupIdx < 0 && hasPrefix(e, "cleanupRun:") {
|
|
288
|
+
cleanupIdx = i
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if endApplyIdx < 0 {
|
|
292
|
+
t.Fatalf("end taskConfig never applied to the parent; events=%v", events)
|
|
293
|
+
}
|
|
294
|
+
if len(fr.cleaned) != 1 {
|
|
295
|
+
t.Fatalf("CleanupRun calls = %v, want 1 with cleanupRunnerOnEnd despite terminal retention", fr.cleaned)
|
|
296
|
+
}
|
|
297
|
+
if cleanupIdx < endApplyIdx {
|
|
298
|
+
t.Fatalf("runner cleanup ran before end taskConfig; events=%v", events)
|
|
299
|
+
}
|
|
300
|
+
r2, _ := engine.GetRun(context.Background(), rid)
|
|
301
|
+
if r2.State == run.StateCompleted && r2.FinishedAt == nil {
|
|
302
|
+
t.Fatal("completed run has no FinishedAt")
|
|
303
|
+
}
|
|
304
|
+
rt, err := engine.GetNodeRuntime(context.Background(), rid, "coding")
|
|
305
|
+
if err != nil {
|
|
306
|
+
t.Fatal(err)
|
|
307
|
+
}
|
|
308
|
+
if rt.TerminalID != "" || rt.SessionID != "session-coding" {
|
|
309
|
+
t.Fatalf("runtime after end cleanup = %+v, want terminal cleared and session retained", rt)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
func TestEndCleanupDisabledKeepsRetainedRunner(t *testing.T) {
|
|
314
|
+
log := newEventLog()
|
|
315
|
+
fr := newFakeRunner(log)
|
|
316
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
317
|
+
Repos: repoRegistryWith("payments", newFakeTaskSystem(log)), Runner: fr, Harness: newFakeHarness(log),
|
|
318
|
+
Runtime: &run.RuntimePolicy{KeepTerminalsAlive: true, KeepSessionsAlive: true},
|
|
319
|
+
})
|
|
320
|
+
rid, _ := startRun(engine, linearWorkflow(false))
|
|
321
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
322
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
323
|
+
return r.CurrentNode == "coding"
|
|
324
|
+
})
|
|
325
|
+
if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end"))); err != nil {
|
|
326
|
+
t.Fatal(err)
|
|
327
|
+
}
|
|
328
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
329
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
330
|
+
return r.State == run.StateCompleted
|
|
331
|
+
})
|
|
332
|
+
if len(fr.cleaned) != 0 || fr.liveTerminals() != 1 {
|
|
333
|
+
t.Fatalf("cleanup disabled: CleanupRun calls=%v live terminals=%d, want 0 and 1", fr.cleaned, fr.liveTerminals())
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// --- 3.16: transition ordering ---
|
|
338
|
+
|
|
339
|
+
func TestTransitionOrdering(t *testing.T) {
|
|
340
|
+
log := newEventLog()
|
|
341
|
+
sys := newFakeTaskSystem(log)
|
|
342
|
+
fr := newFakeRunner(log)
|
|
343
|
+
wf := workflow.Workflow{
|
|
344
|
+
Name: "reviewFlow", Repos: []string{"payments"},
|
|
345
|
+
Nodes: map[string]workflow.Node{
|
|
346
|
+
"start": {OnSuccess: []workflow.Route{{Target: "coding"}}},
|
|
347
|
+
"coding": {
|
|
348
|
+
Type: workflow.NodeAgent, Agent: "build", Description: "code",
|
|
349
|
+
OnSuccess: []workflow.Route{{Target: "review"}},
|
|
350
|
+
OnFailure: []workflow.Route{{Target: "coding"}},
|
|
351
|
+
},
|
|
352
|
+
"review": {
|
|
353
|
+
Type: workflow.NodeHITL, Agent: "reviewer", Description: "review",
|
|
354
|
+
OnSuccess: []workflow.Route{{Target: "end"}},
|
|
355
|
+
OnFailure: []workflow.Route{{Target: "coding"}},
|
|
356
|
+
},
|
|
357
|
+
"end": {},
|
|
358
|
+
},
|
|
359
|
+
}
|
|
360
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
361
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
|
|
362
|
+
})
|
|
363
|
+
rid, _ := startRun(engine, wf)
|
|
364
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
365
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
366
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
367
|
+
})
|
|
368
|
+
report := successReport("review")
|
|
369
|
+
report.Feedback = workflow.Feedback{
|
|
370
|
+
ReasonForNextStep: "ready", RequiredActions: "review it",
|
|
371
|
+
RelevantContext: "diff", ExpectedResult: "approval",
|
|
372
|
+
}
|
|
373
|
+
if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "coding", report)); err != nil {
|
|
374
|
+
t.Fatal(err)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
378
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
379
|
+
return r.CurrentNode == "review"
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
// Exact cross-primitive order, observed through the fake-adapter and fake-
|
|
383
|
+
// runner call logs (the settled observation seam):
|
|
384
|
+
// summary(current) -> feedback(selected next) -> CompleteMailbox(current)
|
|
385
|
+
// -> ApplyTaskConfig(next) -> next terminal.
|
|
386
|
+
// That the report+selected route are persisted BEFORE any of these effects
|
|
387
|
+
// is proven at the outcome level by TestCrashImmediatelyAfterReportPersistence
|
|
388
|
+
// (recovery_test.go): a crash after acceptance, with comment injection
|
|
389
|
+
// failing, restarts on the same db and resumes the PERSISTED selected route
|
|
390
|
+
// without re-asking the agent or re-running effects.
|
|
391
|
+
events := log.all()
|
|
392
|
+
idx := map[string]int{}
|
|
393
|
+
for _, want := range []string{
|
|
394
|
+
"comment:PAY-101-coding", // summary to current mailbox
|
|
395
|
+
"comment:PAY-101-review", // feedback to selected next mailbox
|
|
396
|
+
"completeMailbox:PAY-101-coding", // complete current
|
|
397
|
+
"applyTaskConfig:PAY-101-review", // apply next node config
|
|
398
|
+
"ensureTerminal:PAY-101:review", // start next terminal
|
|
399
|
+
} {
|
|
400
|
+
idx[want] = indexOf(events, want)
|
|
401
|
+
if idx[want] < 0 {
|
|
402
|
+
t.Fatalf("missing event %q; events=%v", want, events)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
order := []string{
|
|
406
|
+
"comment:PAY-101-coding", "comment:PAY-101-review", "completeMailbox:PAY-101-coding",
|
|
407
|
+
"applyTaskConfig:PAY-101-review", "ensureTerminal:PAY-101:review",
|
|
408
|
+
}
|
|
409
|
+
for i := 0; i+1 < len(order); i++ {
|
|
410
|
+
if idx[order[i]] >= idx[order[i+1]] {
|
|
411
|
+
t.Fatalf("order violated: %q(%d) !< %q(%d); events=%v",
|
|
412
|
+
order[i], idx[order[i]], order[i+1], idx[order[i+1]], events)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
func TestEndSkipsFeedbackComment(t *testing.T) {
|
|
418
|
+
log := newEventLog()
|
|
419
|
+
sys := newFakeTaskSystem(log)
|
|
420
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
421
|
+
Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
|
|
422
|
+
})
|
|
423
|
+
rid, _ := startRun(engine, linearWorkflow(false))
|
|
424
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
425
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
426
|
+
return r.CurrentNode == "coding"
|
|
427
|
+
})
|
|
428
|
+
if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end"))); err != nil {
|
|
429
|
+
t.Fatal(err)
|
|
430
|
+
}
|
|
431
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
432
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
433
|
+
return r.State == run.StateCompleted
|
|
434
|
+
})
|
|
435
|
+
for _, e := range log.all() {
|
|
436
|
+
if e == "comment:PAY-end" {
|
|
437
|
+
t.Fatal("feedback comment written to an end mailbox; end has none")
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// --- 3.20/3.22: report delivery, dedup, ack semantics ---
|
|
443
|
+
|
|
444
|
+
func TestReportAckOnlyAfterDurablePersistence(t *testing.T) {
|
|
445
|
+
log := newEventLog()
|
|
446
|
+
sys := newFakeTaskSystem(log)
|
|
447
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
448
|
+
Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
|
|
449
|
+
})
|
|
450
|
+
rid, _ := startRun(engine, linearWorkflow(false))
|
|
451
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
452
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
453
|
+
return r.CurrentNode == "coding"
|
|
454
|
+
})
|
|
455
|
+
ack, err := engine.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end")))
|
|
456
|
+
if err != nil {
|
|
457
|
+
t.Fatal(err)
|
|
458
|
+
}
|
|
459
|
+
if !ack.Accepted || ack.Duplicate {
|
|
460
|
+
t.Fatalf("first report ack = %+v, want {accepted:true, duplicate:false}", ack)
|
|
461
|
+
}
|
|
462
|
+
// After the ack, the report is durably persisted: a crash/restart at
|
|
463
|
+
// this exact point must resume the persisted route without re-asking
|
|
464
|
+
// the agent. Covered by the crash-boundary test in recovery_test.go.
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
func TestNonCurrentVisitAckedAsOldDuplicate(t *testing.T) {
|
|
468
|
+
log := newEventLog()
|
|
469
|
+
sys := newFakeTaskSystem(log)
|
|
470
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
471
|
+
Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
|
|
472
|
+
})
|
|
473
|
+
rid, _ := startRun(engine, linearWorkflow(false))
|
|
474
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
475
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
476
|
+
return r.CurrentNode == "coding"
|
|
477
|
+
})
|
|
478
|
+
req := reportRequest(rid, "coding", successReport("end"))
|
|
479
|
+
if _, err := engine.SubmitReport(context.Background(), req); err != nil {
|
|
480
|
+
t.Fatal(err)
|
|
481
|
+
}
|
|
482
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
483
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
484
|
+
return r.State == run.StateCompleted
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
commentsBefore := log.count("comment:")
|
|
488
|
+
// Once reportId is processed, its body is irrelevant. Even a changed,
|
|
489
|
+
// invalid payload is dropped before validation.
|
|
490
|
+
req.Report.NextStep = "not-a-route"
|
|
491
|
+
ack, err := engine.SubmitReport(context.Background(), req)
|
|
492
|
+
if err != nil {
|
|
493
|
+
t.Fatal(err)
|
|
494
|
+
}
|
|
495
|
+
if !ack.Accepted || !ack.Duplicate {
|
|
496
|
+
t.Fatalf("stale report ack = %+v, want {accepted:true, duplicate:true}", ack)
|
|
497
|
+
}
|
|
498
|
+
if log.count("comment:") != commentsBefore {
|
|
499
|
+
t.Fatal("duplicate report caused repeated mailbox comments")
|
|
500
|
+
}
|
|
501
|
+
if log.count("completeMailbox:") != 1 {
|
|
502
|
+
t.Fatal("duplicate report caused repeated mailbox completion")
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
func TestFirstReportOnlyConsumed(t *testing.T) {
|
|
507
|
+
log := newEventLog()
|
|
508
|
+
sys := newFakeTaskSystem(log)
|
|
509
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
510
|
+
Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
|
|
511
|
+
})
|
|
512
|
+
rid, _ := startRun(engine, linearWorkflow(false))
|
|
513
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
514
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
515
|
+
return r.CurrentNode == "coding"
|
|
516
|
+
})
|
|
517
|
+
req := reportRequest(rid, "coding", successReport("end"))
|
|
518
|
+
ack1, err := engine.SubmitReport(context.Background(), req)
|
|
519
|
+
if err != nil || !ack1.Accepted || ack1.Duplicate {
|
|
520
|
+
t.Fatalf("first ack = %+v err=%v", ack1, err)
|
|
521
|
+
}
|
|
522
|
+
ack2, err := engine.SubmitReport(context.Background(), req)
|
|
523
|
+
if err != nil {
|
|
524
|
+
t.Fatal(err)
|
|
525
|
+
}
|
|
526
|
+
if !ack2.Accepted {
|
|
527
|
+
t.Fatalf("second ack = %+v, want accepted (harmless)", ack2)
|
|
528
|
+
}
|
|
529
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
530
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
531
|
+
return r.State == run.StateCompleted
|
|
532
|
+
})
|
|
533
|
+
if n := log.count("comment:PAY-101-coding"); n != 1 {
|
|
534
|
+
t.Fatalf("coding summaries = %d, want exactly 1 (no repeated graph effects)", n)
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// --- 3.14: run identity (engine-level) ---
|
|
539
|
+
|
|
540
|
+
func TestEnsureRunIdempotentAndVisitStableAcrossReplay(t *testing.T) {
|
|
541
|
+
log := newEventLog()
|
|
542
|
+
sys := newFakeTaskSystem(log)
|
|
543
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
544
|
+
Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
|
|
545
|
+
})
|
|
546
|
+
wf := linearWorkflow(false)
|
|
547
|
+
rid, _ := startRun(engine, wf)
|
|
548
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
549
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
550
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
551
|
+
})
|
|
552
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
553
|
+
visit := r.CurrentNodeVisitID
|
|
554
|
+
|
|
555
|
+
// Repeated EnsureRun with the same deterministic ID returns the existing
|
|
556
|
+
// run without restarting and without changing the current visit.
|
|
557
|
+
created, err := engine.EnsureRun(context.Background(), run.Start{
|
|
558
|
+
ID: rid, Repo: "payments", RepoPath: "/srv/payments", Workflow: wf,
|
|
559
|
+
Ticket: task.TicketRef{ID: "1", Key: "PAY-101"},
|
|
560
|
+
})
|
|
561
|
+
if err != nil {
|
|
562
|
+
t.Fatal(err)
|
|
563
|
+
}
|
|
564
|
+
if created {
|
|
565
|
+
t.Fatal("repeated EnsureRun reported created=true; want existing run")
|
|
566
|
+
}
|
|
567
|
+
r2, _ := engine.GetRun(context.Background(), rid)
|
|
568
|
+
if r2.CurrentNodeVisitID != visit {
|
|
569
|
+
t.Fatalf("visit changed on repeated EnsureRun: %q -> %q", visit, r2.CurrentNodeVisitID)
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// --- helpers ---
|
|
574
|
+
|
|
575
|
+
func repoRegistryWith(name string, sys task.System) *repo.Registry {
|
|
576
|
+
reg := &repo.Registry{}
|
|
577
|
+
reg.Replace(&repo.Repo{Name: name, Path: "/srv/" + name, TaskSystem: sys})
|
|
578
|
+
return reg
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
func waitFor(t *testing.T, d time.Duration, cond func() bool) {
|
|
582
|
+
t.Helper()
|
|
583
|
+
deadline := time.Now().Add(d)
|
|
584
|
+
for time.Now().Before(deadline) {
|
|
585
|
+
if cond() {
|
|
586
|
+
return
|
|
587
|
+
}
|
|
588
|
+
time.Sleep(20 * time.Millisecond)
|
|
589
|
+
}
|
|
590
|
+
t.Fatal("condition not met within " + d.String())
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
func indexOf(events []string, want string) int {
|
|
594
|
+
for i, e := range events {
|
|
595
|
+
if e == want {
|
|
596
|
+
return i
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return -1
|
|
600
|
+
}
|