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.
Files changed (116) hide show
  1. package/README.md +148 -143
  2. package/cmd/relay-flow/commands_test.go +464 -0
  3. package/cmd/relay-flow/main.go +670 -180
  4. package/cmd/relay-flow/scenario_test.go +1135 -0
  5. package/cmd/relay-flow/serve.go +609 -0
  6. package/go.mod +69 -2
  7. package/go.sum +185 -0
  8. package/internal/config/config.go +88 -0
  9. package/internal/config/machine.go +99 -48
  10. package/internal/config/machine_test.go +248 -0
  11. package/internal/config/merge_test.go +118 -0
  12. package/internal/config/writeatomic.go +36 -0
  13. package/internal/config/writeatomic_test.go +98 -0
  14. package/internal/execution/goworkflows/activities.go +490 -0
  15. package/internal/execution/goworkflows/engine.go +487 -0
  16. package/internal/execution/goworkflows/engine_test.go +600 -0
  17. package/internal/execution/goworkflows/fakes_test.go +517 -0
  18. package/internal/execution/goworkflows/interpreter.go +605 -0
  19. package/internal/execution/goworkflows/logging_test.go +154 -0
  20. package/internal/execution/goworkflows/mailbox_test.go +423 -0
  21. package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
  22. package/internal/execution/goworkflows/node_runtime_test.go +486 -0
  23. package/internal/execution/goworkflows/projection.go +504 -0
  24. package/internal/execution/goworkflows/recovery_test.go +1092 -0
  25. package/internal/execution/goworkflows/retry_log_test.go +59 -0
  26. package/internal/execution/goworkflows/retry_projection_test.go +98 -0
  27. package/internal/harness/contract_test.go +169 -0
  28. package/internal/harness/factory.go +63 -0
  29. package/internal/harness/harness.go +41 -0
  30. package/internal/harness/opencode/opencode.go +166 -0
  31. package/internal/harness/opencode/opencode_test.go +50 -0
  32. package/internal/harness/plugin_selection_test.go +126 -0
  33. package/internal/identity/identity.go +37 -0
  34. package/internal/logging/logging.go +56 -0
  35. package/internal/logging/logging_test.go +116 -0
  36. package/internal/paths/paths.go +67 -0
  37. package/internal/recover/recover.go +115 -0
  38. package/internal/repo/poller.go +186 -0
  39. package/internal/repo/poller_test.go +327 -0
  40. package/internal/repo/repo.go +119 -0
  41. package/internal/repo/service.go +216 -0
  42. package/internal/repo/service_test.go +298 -0
  43. package/internal/retry/retry.go +118 -0
  44. package/internal/router/router.go +83 -0
  45. package/internal/router/router_test.go +144 -0
  46. package/internal/run/manager.go +108 -0
  47. package/internal/run/run.go +140 -0
  48. package/internal/run/run_identity_test.go +52 -0
  49. package/internal/run/run_manager_test.go +266 -0
  50. package/internal/runner/contract_test.go +221 -0
  51. package/internal/runner/factory.go +65 -0
  52. package/internal/runner/orca/orca.go +363 -170
  53. package/internal/runner/orca/orca_test.go +134 -160
  54. package/internal/runner/orca/orcacli/orcacli.go +215 -0
  55. package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
  56. package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
  57. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
  58. package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
  59. package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
  60. package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
  61. package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
  62. package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
  63. package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
  64. package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
  65. package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
  66. package/internal/runner/runner.go +47 -64
  67. package/internal/server/api_test.go +300 -0
  68. package/internal/server/client.go +192 -74
  69. package/internal/server/fixture_test.go +248 -0
  70. package/internal/server/server.go +425 -248
  71. package/internal/server/shutdown_test.go +116 -0
  72. package/internal/task/contract_test.go +223 -0
  73. package/internal/task/factory.go +103 -0
  74. package/internal/task/jira/acli/acli.go +306 -0
  75. package/internal/task/jira/acli/acli_test.go +208 -0
  76. package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
  77. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
  78. package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
  79. package/internal/task/jira/acli/testdata/search_success.json +1 -0
  80. package/internal/task/jira/filters_test.go +234 -0
  81. package/internal/task/jira/helpers_test.go +60 -0
  82. package/internal/task/jira/jira.go +507 -0
  83. package/internal/task/jira/normalize.go +101 -0
  84. package/internal/task/jira/testdata/acli_search.json +120 -0
  85. package/internal/task/jira/transition_defaults_test.go +156 -0
  86. package/internal/task/jira/validation_test.go +94 -0
  87. package/internal/task/task.go +84 -0
  88. package/internal/workflow/report.go +85 -0
  89. package/internal/workflow/report_test.go +259 -0
  90. package/internal/workflow/service.go +142 -0
  91. package/internal/workflow/store.go +136 -0
  92. package/internal/workflow/store_test.go +282 -0
  93. package/internal/workflow/workflow.go +342 -0
  94. package/internal/workflow/workflow_test.go +410 -0
  95. package/package.json +1 -1
  96. package/internal/acli/acli.go +0 -229
  97. package/internal/config/demo_test.go +0 -17
  98. package/internal/config/schema.go +0 -193
  99. package/internal/config/schema_test.go +0 -162
  100. package/internal/daemon/daemon.go +0 -218
  101. package/internal/daemon/daemon_test.go +0 -204
  102. package/internal/discovery/discovery.go +0 -122
  103. package/internal/discovery/discovery_test.go +0 -62
  104. package/internal/opencode/opencode.go +0 -26
  105. package/internal/orcacli/orcacli.go +0 -264
  106. package/internal/runner/orca/README.md +0 -64
  107. package/internal/runner/runner_test.go +0 -64
  108. package/internal/server/server_test.go +0 -195
  109. package/internal/tasks/jira/README.md +0 -69
  110. package/internal/tasks/jira/component_test.go +0 -16
  111. package/internal/tasks/jira/decode.go +0 -24
  112. package/internal/tasks/jira/jira.go +0 -231
  113. package/internal/tasks/jira/jira_test.go +0 -259
  114. package/internal/tasks/jira/jql_test.go +0 -16
  115. package/internal/tasks/tasks.go +0 -90
  116. 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
+ }