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,1092 @@
1
+ package goworkflows_test
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "os"
7
+ "path/filepath"
8
+ "testing"
9
+ "time"
10
+
11
+ "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
12
+ "github.com/rajpopat27/relay-flow/internal/identity"
13
+ recoverpkg "github.com/rajpopat27/relay-flow/internal/recover"
14
+ "github.com/rajpopat27/relay-flow/internal/repo"
15
+ "github.com/rajpopat27/relay-flow/internal/run"
16
+ "github.com/rajpopat27/relay-flow/internal/runner"
17
+ "github.com/rajpopat27/relay-flow/internal/task"
18
+ "github.com/rajpopat27/relay-flow/internal/workflow"
19
+ )
20
+
21
+ // 3.14, 3.17-3.21, 3.23-3.26 (engine level): identity/recovery, roll-forward,
22
+ // conflict/blocked, cancellation, terminal reconcile, database lifecycle,
23
+ // recover, retention, projection. Fakes in fakes_test.go.
24
+
25
+ // --- 3.14: fresh visit identity on a fresh run ---
26
+
27
+ func TestFreshRunGeneratesFreshVisitIDs(t *testing.T) {
28
+ // Two independent runs (separate execution state) for the same
29
+ // deterministic run ID generate non-colliding nodeVisitIDs; a fresh run
30
+ // after explicit recovery must not reuse a stale visit ID.
31
+ log := newEventLog()
32
+ sys := newFakeTaskSystem(log)
33
+ fr := newFakeRunner(log)
34
+ wf := linearWorkflow(false)
35
+
36
+ db1 := filepath.Join(t.TempDir(), "state.db")
37
+ deps := goworkflows.Dependencies{Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log)}
38
+ e1, err := goworkflows.New(db1, deps)
39
+ if err != nil {
40
+ t.Fatal(err)
41
+ }
42
+ if err := e1.Start(context.Background()); err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ rid, _ := startRun(e1, wf)
46
+ waitFor(t, 10*time.Second, func() bool {
47
+ r, _ := e1.GetRun(context.Background(), rid)
48
+ return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
49
+ })
50
+ r, _ := e1.GetRun(context.Background(), rid)
51
+ firstVisit := r.CurrentNodeVisitID
52
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
53
+ _ = e1.Shutdown(ctx)
54
+ cancel()
55
+
56
+ db2 := filepath.Join(t.TempDir(), "state.db")
57
+ e2, err := goworkflows.New(db2, deps)
58
+ if err != nil {
59
+ t.Fatal(err)
60
+ }
61
+ if err := e2.Start(context.Background()); err != nil {
62
+ t.Fatal(err)
63
+ }
64
+ defer func() {
65
+ c, cc := context.WithTimeout(context.Background(), 30*time.Second)
66
+ defer cc()
67
+ _ = e2.Shutdown(c)
68
+ }()
69
+ if _, err := e2.EnsureRun(context.Background(), run.Start{
70
+ ID: rid, Repo: "payments", RepoPath: "/srv/payments", Workflow: wf,
71
+ Ticket: task.TicketRef{ID: "1", Key: "PAY-101"},
72
+ }); err != nil {
73
+ t.Fatal(err)
74
+ }
75
+ waitFor(t, 10*time.Second, func() bool {
76
+ r, err := e2.GetRun(context.Background(), rid)
77
+ return err == nil && r.CurrentNodeVisitID != ""
78
+ })
79
+ r2, _ := e2.GetRun(context.Background(), rid)
80
+ if r2.CurrentNodeVisitID == firstVisit {
81
+ t.Fatal("fresh run collided on a prior nodeVisitID; visit IDs must be fresh/random")
82
+ }
83
+ }
84
+
85
+ func TestVisitIDStableAcrossNormalRestart(t *testing.T) {
86
+ // The nodeVisitID is generated once per node entry through a durable
87
+ // replay-safe side effect: a normal Shutdown + New + Start on the SAME
88
+ // database must return the SAME visit ID for the in-progress visit.
89
+ log := newEventLog()
90
+ sys := newFakeTaskSystem(log)
91
+ fr := newFakeRunner(log)
92
+ wf := linearWorkflow(false)
93
+ db := filepath.Join(t.TempDir(), "state.db")
94
+ deps := goworkflows.Dependencies{Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log)}
95
+
96
+ e1, err := goworkflows.New(db, deps)
97
+ if err != nil {
98
+ t.Fatal(err)
99
+ }
100
+ if err := e1.Start(context.Background()); err != nil {
101
+ t.Fatal(err)
102
+ }
103
+ rid, _ := startRun(e1, wf)
104
+ waitFor(t, 10*time.Second, func() bool {
105
+ r, _ := e1.GetRun(context.Background(), rid)
106
+ return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
107
+ })
108
+ r, _ := e1.GetRun(context.Background(), rid)
109
+ visit := r.CurrentNodeVisitID
110
+
111
+ e2 := restartEngine(t, db, deps, e1)
112
+ waitFor(t, 10*time.Second, func() bool {
113
+ r, err := e2.GetRun(context.Background(), rid)
114
+ return err == nil && r.CurrentNodeVisitID != ""
115
+ })
116
+ r2, _ := e2.GetRun(context.Background(), rid)
117
+ if r2.CurrentNodeVisitID != visit {
118
+ t.Fatalf("visit changed across normal restart: %q -> %q; must be replay-stable", visit, r2.CurrentNodeVisitID)
119
+ }
120
+ // The unfinished run RESUMES work on the same db after restart: the
121
+ // engine re-drives the in-progress node (re-ensures its terminal and
122
+ // resumes the harness), it does not abandon the run.
123
+ if r2.State == run.StateCanceled || r2.State == run.StateCompleted {
124
+ t.Fatalf("unfinished run %s in terminal state %q after restart; it must resume", rid, r2.State)
125
+ }
126
+ waitFor(t, 10*time.Second, func() bool {
127
+ return log.count("buildCommand:") > 0 && fr.liveTerminals() > 0
128
+ })
129
+ // The resumed visit can still complete normally: submit the report for
130
+ // the configured end route and the run completes.
131
+ if _, err := e2.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end"))); err != nil {
132
+ t.Fatalf("resumed run rejected its report: %v", err)
133
+ }
134
+ waitFor(t, 10*time.Second, func() bool {
135
+ r, _ := e2.GetRun(context.Background(), rid)
136
+ return r.State == run.StateCompleted
137
+ })
138
+ }
139
+
140
+ // --- 3.17: roll-forward recovery across crash boundaries ---
141
+
142
+ // restart reopens the engine on the same DB (a crash/restart boundary).
143
+ func restartEngine(t *testing.T, db string, deps goworkflows.Dependencies, old *goworkflows.Engine) *goworkflows.Engine {
144
+ t.Helper()
145
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
146
+ _ = old.Shutdown(ctx) // crash boundary: durable state persists
147
+ cancel()
148
+ e, err := goworkflows.New(db, deps)
149
+ if err != nil {
150
+ t.Fatal(err)
151
+ }
152
+ if err := e.Start(context.Background()); err != nil {
153
+ t.Fatal(err)
154
+ }
155
+ t.Cleanup(func() {
156
+ c, cc := context.WithTimeout(context.Background(), 30*time.Second)
157
+ defer cc()
158
+ _ = e.Shutdown(c)
159
+ })
160
+ return e
161
+ }
162
+
163
+ func TestCancelRun(t *testing.T) {
164
+ log := newEventLog()
165
+ sys := newFakeTaskSystem(log)
166
+ fr := newFakeRunner(log)
167
+ engine := newEngine(t, goworkflows.Dependencies{
168
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
169
+ Runtime: &run.RuntimePolicy{},
170
+ })
171
+ rid, _ := startRun(engine, linearWorkflow(false))
172
+ waitFor(t, 10*time.Second, func() bool {
173
+ r, _ := engine.GetRun(context.Background(), rid)
174
+ return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
175
+ })
176
+ if fr.liveTerminals() == 0 {
177
+ t.Fatal("no live terminal before cancellation")
178
+ }
179
+
180
+ if err := engine.CancelRun(context.Background(), rid, "no longer needed"); err != nil {
181
+ t.Fatalf("CancelRun failed: %v", err)
182
+ }
183
+ waitFor(t, 30*time.Second, func() bool {
184
+ r, _ := engine.GetRun(context.Background(), rid)
185
+ return r.State == run.StateCanceled
186
+ })
187
+
188
+ // Run-owned terminals closed, environment/workspace preserved.
189
+ if fr.liveTerminals() != 0 {
190
+ t.Fatal("cancellation left live terminals")
191
+ }
192
+ if log.count("closeTerminal:PAY-101:coding") != 1 {
193
+ t.Fatalf("direct terminal closes = %v, want coding terminal closed once", log.all())
194
+ }
195
+ if len(fr.envs) != 1 {
196
+ t.Fatal("cancellation removed the environment; workspace/code must be preserved")
197
+ }
198
+
199
+ // Exactly one parent cancellation comment with the stable marker.
200
+ wantMarker := string(rid) + ":cancellation"
201
+ var cancelComments int
202
+ for _, c := range sys.commentBodies("PAY-101") {
203
+ if c.Marker == wantMarker {
204
+ cancelComments++
205
+ }
206
+ }
207
+ if cancelComments != 1 {
208
+ t.Fatalf("cancellation comments = %d, want 1 with marker %q", cancelComments, wantMarker)
209
+ }
210
+
211
+ // Mailbox statuses/history unchanged: the in-flight coding mailbox was
212
+ // not completed by cancellation.
213
+ if sys.mailboxStatusOf("PAY-101-coding") == "Done" {
214
+ t.Fatal("cancellation completed the in-flight mailbox; mailbox state must be unchanged")
215
+ }
216
+ // No further node activity scheduled after cancellation.
217
+ if log.count("ensureTerminal:PAY-101:review") != 0 {
218
+ t.Fatal("activity scheduled after cancellation")
219
+ }
220
+ }
221
+
222
+ func TestCancelDuringRunningActivity(t *testing.T) {
223
+ // Cancellation cannot interrupt an already-running activity; it waits
224
+ // for it to return, then runs cancellation cleanup.
225
+ log := newEventLog()
226
+ sys := newFakeTaskSystem(log)
227
+ fr := newFakeRunner(log)
228
+ engine := newEngine(t, goworkflows.Dependencies{
229
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
230
+ Runtime: &run.RuntimePolicy{},
231
+ })
232
+ rid, _ := startRun(engine, linearWorkflow(false))
233
+ waitFor(t, 10*time.Second, func() bool {
234
+ r, _ := engine.GetRun(context.Background(), rid)
235
+ return r.CurrentNode == "coding"
236
+ })
237
+ sys.completeSlow = 500 * time.Millisecond
238
+ if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end"))); err != nil {
239
+ t.Fatal(err)
240
+ }
241
+ waitFor(t, 10*time.Second, func() bool { return log.count("completeMailboxStart:") > 0 })
242
+
243
+ if err := engine.CancelRun(context.Background(), rid, "stop"); err != nil {
244
+ t.Fatal(err)
245
+ }
246
+ waitFor(t, 30*time.Second, func() bool {
247
+ r, _ := engine.GetRun(context.Background(), rid)
248
+ return r.State == run.StateCanceled
249
+ })
250
+
251
+ // Cancellation performs no mailbox mutation of its own. Snapshot the
252
+ // mailbox state after the run is canceled (the in-flight activity has
253
+ // already returned by then — cancel waits for it) and assert it stays
254
+ // stable: no further status change or comment is written afterward.
255
+ statusAfterCancel := sys.mailboxStatusOf("PAY-101-coding")
256
+ commentsAfterCancel := len(sys.commentBodies("PAY-101-coding"))
257
+ time.Sleep(300 * time.Millisecond)
258
+ if got := sys.mailboxStatusOf("PAY-101-coding"); got != statusAfterCancel {
259
+ t.Fatalf("mailbox status changed after cancellation: %q -> %q", statusAfterCancel, got)
260
+ }
261
+ if got := len(sys.commentBodies("PAY-101-coding")); got != commentsAfterCancel {
262
+ t.Fatalf("mailbox comments grew after cancellation: %d -> %d", commentsAfterCancel, got)
263
+ }
264
+ // Deterministic ordering via the fake-adapter/runner call log: the
265
+ // in-flight activity returned (completeMailbox) BEFORE terminal cleanup
266
+ // (closeTerminals). Cancellation waited for the running activity.
267
+ events := log.all()
268
+ completeIdx := indexOf(events, "completeMailbox:PAY-101-coding")
269
+ closeIdx := -1
270
+ for i, e := range events {
271
+ if hasPrefix(e, "closeTerminal:") {
272
+ closeIdx = i
273
+ break
274
+ }
275
+ }
276
+ if completeIdx < 0 {
277
+ t.Fatal("running activity never completed before cancellation cleanup")
278
+ }
279
+ if closeIdx < 0 || closeIdx < completeIdx {
280
+ t.Fatalf("terminal cleanup ran before the in-flight activity returned; events=%v", events)
281
+ }
282
+ if log.count("closeTerminal:PAY-101:coding") != 1 {
283
+ t.Fatalf("direct terminal closes = %v, want 1", log.all())
284
+ }
285
+ // Cancellation itself performs no mailbox mutation beyond the single
286
+ // in-flight activity's own completion, and schedules no further work.
287
+ if n := log.count("completeMailbox:PAY-101-coding"); n != 1 {
288
+ t.Fatalf("completeMailbox ran %d times; want exactly 1 (the in-flight activity only, no extra mutation)", n)
289
+ }
290
+ if log.count("ensureTerminal:PAY-101:review") != 0 {
291
+ t.Fatal("activity scheduled after cancellation")
292
+ }
293
+ }
294
+
295
+ // --- 3.17: roll-forward recovery across crash boundaries ---
296
+
297
+ func TestCrashImmediatelyAfterReportPersistence(t *testing.T) {
298
+ // Crash after the report+route are persisted but before any comment is
299
+ // written: replay resumes the persisted route without re-asking the
300
+ // agent and runs the first unfinished activity.
301
+ log := newEventLog()
302
+ sys := newFakeTaskSystem(log)
303
+ fr := newFakeRunner(log)
304
+ db := filepath.Join(t.TempDir(), "state.db")
305
+ wf := linearWorkflow(false)
306
+ deps := goworkflows.Dependencies{Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log)}
307
+
308
+ e1, err := goworkflows.New(db, deps)
309
+ if err != nil {
310
+ t.Fatal(err)
311
+ }
312
+ if err := e1.Start(context.Background()); err != nil {
313
+ t.Fatal(err)
314
+ }
315
+ rid, _ := startRun(e1, wf)
316
+ waitFor(t, 10*time.Second, func() bool {
317
+ r, _ := e1.GetRun(context.Background(), rid)
318
+ return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
319
+ })
320
+ // Fail comments so the report+route persist but no summary lands.
321
+ sys.failComments = true
322
+ if _, err := e1.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end"))); err != nil {
323
+ t.Fatal(err)
324
+ }
325
+ // Let the report be consumed and the first comment attempt fail.
326
+ waitFor(t, 10*time.Second, func() bool { return log.count("commentFail:") > 0 })
327
+ buildBefore := log.count("buildCommand:")
328
+
329
+ // Crash (Shutdown) then New+Start on the same db.
330
+ e2 := restartEngine(t, db, deps, e1)
331
+ sys.failComments = false
332
+
333
+ waitFor(t, 30*time.Second, func() bool {
334
+ r, _ := e2.GetRun(context.Background(), rid)
335
+ return r.State == run.StateCompleted
336
+ })
337
+ if log.count("buildCommand:") != buildBefore {
338
+ t.Fatal("agent re-asked after crash; persisted route must resume without a new LLM turn")
339
+ }
340
+ if n := log.count("comment:PAY-101-coding"); n != 1 {
341
+ t.Fatalf("summaries = %d, want exactly 1 after roll-forward", n)
342
+ }
343
+ }
344
+
345
+ func TestCrashAfterSummaryFeedbackBeforeCompletion(t *testing.T) {
346
+ // Crash after summary+feedback are written but before CompleteMailbox
347
+ // succeeds: replay retains the route and retries only completion. Uses a
348
+ // coding->review route so a FEEDBACK activity exists.
349
+ log := newEventLog()
350
+ sys := newFakeTaskSystem(log)
351
+ fr := newFakeRunner(log)
352
+ db := filepath.Join(t.TempDir(), "state.db")
353
+ wf := workflow.Workflow{
354
+ Name: "reviewFlow", Repos: []string{"payments"},
355
+ Nodes: map[string]workflow.Node{
356
+ "start": {OnSuccess: []workflow.Route{{Target: "coding"}}},
357
+ "coding": {
358
+ Type: workflow.NodeAgent, Agent: "build", Description: "code",
359
+ OnSuccess: []workflow.Route{{Target: "review"}},
360
+ OnFailure: []workflow.Route{{Target: "coding"}},
361
+ },
362
+ "review": {
363
+ Type: workflow.NodeHITL, Agent: "reviewer", Description: "review",
364
+ OnSuccess: []workflow.Route{{Target: "end"}},
365
+ OnFailure: []workflow.Route{{Target: "coding"}},
366
+ },
367
+ "end": {},
368
+ },
369
+ }
370
+ deps := goworkflows.Dependencies{Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log)}
371
+
372
+ e1, err := goworkflows.New(db, deps)
373
+ if err != nil {
374
+ t.Fatal(err)
375
+ }
376
+ if err := e1.Start(context.Background()); err != nil {
377
+ t.Fatal(err)
378
+ }
379
+ rid, _ := startRun(e1, wf)
380
+ waitFor(t, 10*time.Second, func() bool {
381
+ r, _ := e1.GetRun(context.Background(), rid)
382
+ return r.CurrentNode == "coding"
383
+ })
384
+ report := successReport("review")
385
+ report.Feedback = workflow.Feedback{
386
+ ReasonForNextStep: "ready", RequiredActions: "review it",
387
+ RelevantContext: "diff", ExpectedResult: "approval",
388
+ }
389
+ sys.completeFail = 100
390
+ if _, err := e1.SubmitReport(context.Background(), reportRequest(rid, "coding", report)); err != nil {
391
+ t.Fatal(err)
392
+ }
393
+ // Summary to coding AND feedback to review both written; completion fails.
394
+ waitFor(t, 10*time.Second, func() bool {
395
+ return log.count("comment:PAY-101-coding") > 0 &&
396
+ log.count("comment:PAY-101-review") > 0 &&
397
+ log.count("completeMailboxFail:") > 0
398
+ })
399
+ summaryBefore := log.count("comment:PAY-101-coding")
400
+ feedbackBefore := log.count("comment:PAY-101-review")
401
+
402
+ sys.completeFail = 0
403
+ e2 := restartEngine(t, db, deps, e1)
404
+ // Run advances to review after the retried completion.
405
+ waitFor(t, 30*time.Second, func() bool {
406
+ r, _ := e2.GetRun(context.Background(), rid)
407
+ return r.CurrentNode == "review"
408
+ })
409
+ // No duplicated summary or feedback; only the completion was retried.
410
+ if log.count("comment:PAY-101-coding") != summaryBefore {
411
+ t.Fatal("summary rewritten after crash; roll-forward must not repeat it")
412
+ }
413
+ if log.count("comment:PAY-101-review") != feedbackBefore {
414
+ t.Fatal("feedback rewritten after crash; roll-forward must not repeat it")
415
+ }
416
+ if log.count("completeMailbox:PAY-101-coding") < 1 {
417
+ t.Fatal("unfinished CompleteMailbox not retried after crash")
418
+ }
419
+ }
420
+
421
+ // --- 3.18: conflict/blocked ---
422
+
423
+ func TestConflictMarksBlockedThenRecovers(t *testing.T) {
424
+ log := newEventLog()
425
+ sys := newFakeTaskSystem(log)
426
+ sys.completeConflict = true
427
+ engine := newEngine(t, goworkflows.Dependencies{
428
+ Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
429
+ })
430
+ rid, _ := startRun(engine, linearWorkflow(false))
431
+ waitFor(t, 10*time.Second, func() bool {
432
+ r, _ := engine.GetRun(context.Background(), rid)
433
+ return r.CurrentNode == "coding"
434
+ })
435
+ if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end"))); err != nil {
436
+ t.Fatal(err)
437
+ }
438
+
439
+ waitFor(t, 30*time.Second, func() bool {
440
+ r, _ := engine.GetRun(context.Background(), rid)
441
+ return r.State == run.StateBlocked
442
+ })
443
+ r, _ := engine.GetRun(context.Background(), rid)
444
+ if r.LastError == "" {
445
+ t.Fatal("blocked run exposes no conflict error in LastError")
446
+ }
447
+ if sys.mailboxStatusOf("PAY-101-coding") == "Done" {
448
+ t.Fatal("mailbox completed while state was incompatible; no blind overwrite allowed")
449
+ }
450
+ if log.count("completeMailboxConflict:") < 1 {
451
+ t.Fatal("no conflict retry observed")
452
+ }
453
+
454
+ // The run stays blocked and keeps retrying with backoff while the
455
+ // conflict persists; it does not complete or overwrite.
456
+ conflictsAfterBlocked := log.count("completeMailboxConflict:")
457
+ waitFor(t, 5*time.Second, func() bool {
458
+ return log.count("completeMailboxConflict:") > conflictsAfterBlocked
459
+ })
460
+ r, _ = engine.GetRun(context.Background(), rid)
461
+ if r.State != run.StateBlocked {
462
+ t.Fatalf("run left blocked while conflict persists: %q", r.State)
463
+ }
464
+
465
+ sys.completeConflict = false
466
+ waitFor(t, 60*time.Second, func() bool {
467
+ r, _ := engine.GetRun(context.Background(), rid)
468
+ return r.State == run.StateCompleted
469
+ })
470
+ }
471
+
472
+ // --- 3.21: terminal reconcile ---
473
+
474
+ func TestTerminalReconcile(t *testing.T) {
475
+ log := newEventLog()
476
+ sys := newFakeTaskSystem(log)
477
+ fr := newFakeRunner(log)
478
+ fh := newFakeHarness(log)
479
+ engine := newEngine(t, goworkflows.Dependencies{
480
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: fh,
481
+ })
482
+ wf := linearWorkflow(false)
483
+ rid, _ := startRun(engine, wf)
484
+ waitFor(t, 10*time.Second, func() bool {
485
+ r, _ := engine.GetRun(context.Background(), rid)
486
+ return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
487
+ })
488
+ r, _ := engine.GetRun(context.Background(), rid)
489
+ visit := r.CurrentNodeVisitID
490
+ terminalsBefore := fr.liveTerminals()
491
+ relaunchBefore := log.count("ensureTerminal:PAY-101:coding")
492
+ buildBefore := log.count("buildCommand:")
493
+ inspectBefore := log.count("inspectTerminal:")
494
+
495
+ // Healthy terminal: EnsureRun checks the persisted direct handle and,
496
+ // finding it live, sends no reconcile and relaunches
497
+ // nothing.
498
+ if _, err := engine.EnsureRun(context.Background(), run.Start{
499
+ ID: rid, Repo: "payments", RepoPath: "/srv/payments", Workflow: wf,
500
+ Ticket: task.TicketRef{ID: "1", Key: "PAY-101"},
501
+ }); err != nil {
502
+ t.Fatal(err)
503
+ }
504
+ time.Sleep(300 * time.Millisecond)
505
+ if log.count("inspectTerminal:") == inspectBefore {
506
+ t.Fatal("repeated EnsureRun never checked the persisted terminal handle")
507
+ }
508
+ if got := log.count("buildCommand:") - buildBefore; got != 0 {
509
+ t.Fatalf("healthy-terminal EnsureRun relaunched the harness %d times; want 0", got)
510
+ }
511
+ if got := log.count("ensureTerminal:PAY-101:coding") - relaunchBefore; got != 0 {
512
+ t.Fatalf("healthy-terminal EnsureRun recreated the terminal %d times; want 0", got)
513
+ }
514
+ r2, _ := engine.GetRun(context.Background(), rid)
515
+ if r2.CurrentNodeVisitID != visit {
516
+ t.Fatal("healthy-terminal EnsureRun changed the current visit")
517
+ }
518
+ if fr.liveTerminals() != terminalsBefore {
519
+ t.Fatal("healthy-terminal EnsureRun relaunched/closed a terminal")
520
+ }
521
+
522
+ // Terminal died: reconcile relaunches the same visit (same nodeVisitID).
523
+ fr.killTerminals()
524
+ if _, err := engine.EnsureRun(context.Background(), run.Start{
525
+ ID: rid, Repo: "payments", RepoPath: "/srv/payments", Workflow: wf,
526
+ Ticket: task.TicketRef{ID: "1", Key: "PAY-101"},
527
+ }); err != nil {
528
+ t.Fatal(err)
529
+ }
530
+ waitFor(t, 10*time.Second, func() bool {
531
+ return log.count("ensureTerminal:PAY-101:coding") > relaunchBefore
532
+ })
533
+ r3, _ := engine.GetRun(context.Background(), rid)
534
+ if r3.CurrentNodeVisitID != visit {
535
+ t.Fatalf("reconcile relaunched with new visit %q, want same visit %q", r3.CurrentNodeVisitID, visit)
536
+ }
537
+ if got := log.count("buildCommand:") - buildBefore; got != 1 {
538
+ t.Fatalf("reconcile relaunched harness %d times, want exactly 1 for the same visit", got)
539
+ }
540
+ }
541
+
542
+ func TestHITLReconcileRestoresMissingSessionNeverNudgesIdle(t *testing.T) {
543
+ log := newEventLog()
544
+ sys := newFakeTaskSystem(log)
545
+ fr := newFakeRunner(log)
546
+ fh := newFakeHarness(log)
547
+ wf := workflow.Workflow{
548
+ Name: "hitlFlow", Repos: []string{"payments"},
549
+ Nodes: map[string]workflow.Node{
550
+ "start": {OnSuccess: []workflow.Route{{Target: "review"}}},
551
+ "review": {
552
+ Type: workflow.NodeHITL, Agent: "reviewer", Description: "review",
553
+ OnSuccess: []workflow.Route{{Target: "end"}},
554
+ OnFailure: []workflow.Route{{Target: "review"}},
555
+ },
556
+ "end": {},
557
+ },
558
+ }
559
+ engine := newEngine(t, goworkflows.Dependencies{
560
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: fh,
561
+ })
562
+ rid := identity.NewRunID("payments", "hitlFlow", "PAY-101")
563
+ start := run.Start{
564
+ ID: rid, Repo: "payments", RepoPath: "/srv/payments", Workflow: wf,
565
+ Ticket: task.TicketRef{ID: "1", Key: "PAY-101"},
566
+ }
567
+ if _, err := engine.EnsureRun(context.Background(), start); err != nil {
568
+ t.Fatal(err)
569
+ }
570
+ waitFor(t, 10*time.Second, func() bool {
571
+ r, _ := engine.GetRun(context.Background(), rid)
572
+ return r.CurrentNode == "review"
573
+ })
574
+ r, _ := engine.GetRun(context.Background(), rid)
575
+ visit := r.CurrentNodeVisitID
576
+
577
+ // Idle live HITL terminal: reconcile leaves it untouched, no nudge.
578
+ buildBefore := log.count("buildCommand:")
579
+ if _, err := engine.EnsureRun(context.Background(), start); err != nil {
580
+ t.Fatal(err)
581
+ }
582
+ time.Sleep(300 * time.Millisecond)
583
+ if fh.reconcileNudge != 0 {
584
+ t.Fatal("idle live HITL session was nudged; HITL reconcile never nudges")
585
+ }
586
+ if log.count("buildCommand:") != buildBefore {
587
+ t.Fatal("idle live HITL session was relaunched")
588
+ }
589
+
590
+ // Missing session/terminal: reconcile restores it for the same visit,
591
+ // without nudging.
592
+ fr.killTerminals()
593
+ if _, err := engine.EnsureRun(context.Background(), start); err != nil {
594
+ t.Fatal(err)
595
+ }
596
+ waitFor(t, 10*time.Second, func() bool {
597
+ return log.count("buildCommand:") > buildBefore
598
+ })
599
+ r2, _ := engine.GetRun(context.Background(), rid)
600
+ if r2.CurrentNodeVisitID != visit {
601
+ t.Fatal("HITL restore changed the visit")
602
+ }
603
+ if fh.reconcileNudge != 0 {
604
+ t.Fatal("HITL restore nudged the session; restore must not nudge")
605
+ }
606
+ }
607
+
608
+ // --- 3.20: report persistence / ack boundary ---
609
+
610
+ func TestAckImpliesDurablePersistenceAcrossRestart(t *testing.T) {
611
+ // A report is acknowledged only after its signal is durably persisted.
612
+ // Proof: submit+ack, then crash, then restart — the run resumes the
613
+ // persisted route without the report being resubmitted, and a post-
614
+ // restart retry of the same visit is a safe duplicate.
615
+ log := newEventLog()
616
+ sys := newFakeTaskSystem(log)
617
+ fr := newFakeRunner(log)
618
+ dir := t.TempDir()
619
+ db := filepath.Join(dir, "state.db")
620
+ wf := linearWorkflow(false)
621
+ deps := goworkflows.Dependencies{Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log)}
622
+
623
+ e1, err := goworkflows.New(db, deps)
624
+ if err != nil {
625
+ t.Fatal(err)
626
+ }
627
+ if err := e1.Start(context.Background()); err != nil {
628
+ t.Fatal(err)
629
+ }
630
+ rid, _ := startRun(e1, wf)
631
+ waitFor(t, 10*time.Second, func() bool {
632
+ r, _ := e1.GetRun(context.Background(), rid)
633
+ return r.CurrentNode == "coding"
634
+ })
635
+ req := reportRequest(rid, "coding", successReport("end"))
636
+ ack, err := e1.SubmitReport(context.Background(), req)
637
+ if err != nil || !ack.Accepted || ack.Duplicate {
638
+ t.Fatalf("first ack = %+v err=%v", ack, err)
639
+ }
640
+
641
+ // Crash after persistence, restart, resume the persisted route.
642
+ e2 := restartEngine(t, db, deps, e1)
643
+ waitFor(t, 30*time.Second, func() bool {
644
+ r, _ := e2.GetRun(context.Background(), rid)
645
+ return r.State == run.StateCompleted
646
+ })
647
+ // The run advanced using the persisted report; no agent re-ask, no
648
+ // repeated summary.
649
+ if n := log.count("comment:PAY-101-coding"); n != 1 {
650
+ t.Fatalf("summaries after restart = %d, want 1 (persisted route resumed)", n)
651
+ }
652
+
653
+ // A post-restart retry of the same visit is a safe duplicate.
654
+ commentsBefore := log.count("comment:")
655
+ ack, err = e2.SubmitReport(context.Background(), req)
656
+ if err != nil || !ack.Accepted {
657
+ t.Fatalf("post-restart retry: ack=%+v err=%v", ack, err)
658
+ }
659
+ if log.count("comment:") != commentsBefore {
660
+ t.Fatal("post-restart duplicate caused repeated effects")
661
+ }
662
+ }
663
+
664
+ // --- 3.21: terminal reconcile ---
665
+
666
+ func TestHealthyDatabaseMissingRunIsClaimBeforeRun(t *testing.T) {
667
+ // With a healthy DB, a labeled ticket whose deterministic run is missing
668
+ // is a claim-before-run crash; EnsureRun creates it normally. Database
669
+ // loss is never inferred from this.
670
+ log := newEventLog()
671
+ sys := newFakeTaskSystem(log)
672
+ engine := newEngine(t, goworkflows.Dependencies{
673
+ Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
674
+ })
675
+ // The DB is valid (engine started). The labeled ticket has no run yet.
676
+ rid := identity.NewRunID("payments", "basicFlow", "PAY-101")
677
+ if _, err := engine.GetRun(context.Background(), rid); err == nil {
678
+ t.Fatal("run unexpectedly exists before EnsureRun")
679
+ }
680
+ created, err := engine.EnsureRun(context.Background(), run.Start{
681
+ ID: rid, Repo: "payments", RepoPath: "/srv/payments", Workflow: linearWorkflow(false),
682
+ Ticket: task.TicketRef{ID: "1", Key: "PAY-101"},
683
+ })
684
+ if err != nil {
685
+ t.Fatal(err)
686
+ }
687
+ if !created {
688
+ t.Fatal("missing claimed run not created in healthy database")
689
+ }
690
+ }
691
+
692
+ // Normal serve refusing a missing/corrupt database is a STARTUP-WIRING
693
+ // behavior (section 5.5), not an engine-New behavior: the recovery and
694
+ // retention tests legitimately create a fresh database via New on a new path.
695
+ // The refusal is covered at the serve layer by 3.23's serve-fixture test
696
+ // (cmd/relay-flow/commands_test.go); see the recovered-vs-normal distinction
697
+ // in TestRecoverResetsJiraStateFreshRuns.
698
+ func TestNormalServeRequiresExistingDatabase(t *testing.T) {
699
+ // An unusable/corrupt database file (wrong magic) makes New fail because
700
+ // SQLite cannot open it — this is genuine engine-level behavior, distinct
701
+ // from the "missing file" case which New tolerates by creating the db.
702
+ deps := goworkflows.Dependencies{
703
+ Repos: repoRegistryWith("payments", newFakeTaskSystem(newEventLog())),
704
+ Runner: newFakeRunner(newEventLog()), Harness: newFakeHarness(newEventLog()),
705
+ }
706
+ path := filepath.Join(t.TempDir(), "state.db")
707
+ if err := os.WriteFile(path, []byte("not a sqlite database"), 0600); err != nil {
708
+ t.Fatal(err)
709
+ }
710
+ if _, err := goworkflows.New(path, deps); err == nil {
711
+ t.Fatal("engine start on a corrupt database succeeded; must fail")
712
+ }
713
+ }
714
+
715
+ func TestDatabaseFileIsOwnerOnly(t *testing.T) {
716
+ // 3.29: the engine-created state.db is mode 0600.
717
+ dir := t.TempDir()
718
+ db := filepath.Join(dir, "state.db")
719
+ engine, err := goworkflows.New(db, goworkflows.Dependencies{
720
+ Repos: repoRegistryWith("payments", newFakeTaskSystem(newEventLog())),
721
+ Runner: newFakeRunner(newEventLog()), Harness: newFakeHarness(newEventLog()),
722
+ })
723
+ if err != nil {
724
+ t.Fatal(err)
725
+ }
726
+ if err := engine.Start(context.Background()); err != nil {
727
+ t.Fatal(err)
728
+ }
729
+ defer func() {
730
+ c, cc := context.WithTimeout(context.Background(), 30*time.Second)
731
+ defer cc()
732
+ _ = engine.Shutdown(c)
733
+ }()
734
+ fi, err := os.Stat(db)
735
+ if err != nil {
736
+ t.Fatalf("state.db not created: %v", err)
737
+ }
738
+ if fi.Mode().Perm() != 0600 {
739
+ t.Fatalf("state.db mode = %o, want 0600", fi.Mode().Perm())
740
+ }
741
+ }
742
+
743
+ // --- 3.24: serve --recover ---
744
+
745
+ // recoverTickets drives the REAL section-5.6 recover composition
746
+ // (internal/recover.FromTaskSystem) against the settled seams: fakes behind
747
+ // task.System/runner.Runner plus the real engine and the real RunManager.
748
+ // Rewritten in 6.3 from a test-local copy so the production wiring is
749
+ // actually covered.
750
+ func recoverTickets(ctx context.Context, engine *goworkflows.Engine, sys *fakeTaskSystem, fr *fakeRunner, wf workflow.Workflow) error {
751
+ reg := repo.NewRegistry()
752
+ reg.Replace(&repo.Repo{
753
+ Name: "payments",
754
+ Path: "/srv/payments",
755
+ TaskSystem: sys,
756
+ })
757
+ // The router needs the workflow bound to the repo to resolve wf: claims.
758
+ if err := reg.BindWorkflows([]*workflow.Workflow{&wf}); err != nil {
759
+ return err
760
+ }
761
+ rm := &run.RunManager{Executor: engine, Runs: engine}
762
+ specsFor := func(w *workflow.Workflow, key string) []task.MailboxSpec {
763
+ return goworkflows.MailboxSpecs(w, key)
764
+ }
765
+ return recoverpkg.FromTaskSystem(ctx, reg, fr, rm, specsFor)
766
+ }
767
+
768
+ func TestServeRecoverRebuildsFreshRuns(t *testing.T) {
769
+ log := newEventLog()
770
+ sys := newFakeTaskSystem(log)
771
+ fr := newFakeRunner(log)
772
+ fh := newFakeHarness(log)
773
+ db := filepath.Join(t.TempDir(), "state.db")
774
+
775
+ sys.parentsToRecover = []task.Ticket{
776
+ {ID: "1", Key: "PAY-101", WorkflowClaims: []string{"wf:basicFlow"}},
777
+ {ID: "2", Key: "PAY-102", WorkflowClaims: []string{"wf:basicFlow"}},
778
+ {ID: "3", Key: "PAY-103", WorkflowClaims: []string{"wf:basicFlow"}}, // canceled
779
+ }
780
+ sys.canceledParents = map[string]bool{"PAY-103": true}
781
+ sys.seedMailbox("PAY-101", task.Mailbox{ID: "mb-coding", Key: "PAY-101-coding", Node: "coding"}, []string{"wf:basicFlow"})
782
+ if err := sys.Comment(context.Background(), task.Target{
783
+ Parent: task.TicketRef{ID: "1", Key: "PAY-101"},
784
+ Mailbox: mailboxPtr(sys, "PAY-101", "coding"),
785
+ }, "old summary", "old"); err != nil {
786
+ t.Fatal(err)
787
+ }
788
+
789
+ // A surviving run-owned terminal from before the loss.
790
+ survSpec := runner.RunSpec{
791
+ RunID: identity.NewRunID("payments", "basicFlow", "PAY-101"),
792
+ RepoName: "payments", RepoPath: "/srv/payments", TicketKey: "PAY-101",
793
+ }
794
+ env, _ := fr.EnsureEnvironment(context.Background(), survSpec)
795
+ _, _ = fr.EnsureTerminal(context.Background(), env, "PAY-101:coding", runner.Command{})
796
+
797
+ deps := goworkflows.Dependencies{Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: fh}
798
+
799
+ // Pre-loss: a real run produces a visit ID that must NOT be reused.
800
+ preLossDB := filepath.Join(t.TempDir(), "state.db")
801
+ preEngine, err := goworkflows.New(preLossDB, deps)
802
+ if err != nil {
803
+ t.Fatal(err)
804
+ }
805
+ if err := preEngine.Start(context.Background()); err != nil {
806
+ t.Fatal(err)
807
+ }
808
+ preRid, _ := startRun(preEngine, linearWorkflow(false))
809
+ waitFor(t, 10*time.Second, func() bool {
810
+ r, _ := preEngine.GetRun(context.Background(), preRid)
811
+ return r.CurrentNodeVisitID != ""
812
+ })
813
+ preR, _ := preEngine.GetRun(context.Background(), preRid)
814
+ preLossVisit := preR.CurrentNodeVisitID
815
+ if _, err := preEngine.RegisterNodeSession(context.Background(), run.NodeRuntimeRegistration{
816
+ RunID: preRid, Node: "coding", SessionID: "pre-loss-session",
817
+ }); err != nil {
818
+ t.Fatal(err)
819
+ }
820
+ preRuntime, err := preEngine.GetNodeRuntime(context.Background(), preRid, "coding")
821
+ if err != nil || preRuntime.TerminalID == "" {
822
+ t.Fatalf("pre-loss runtime = %+v, %v", preRuntime, err)
823
+ }
824
+ inspectBeforeRecover := log.count("inspectTerminal:")
825
+ pc, pcancel := context.WithTimeout(context.Background(), 30*time.Second)
826
+ _ = preEngine.Shutdown(pc)
827
+ pcancel()
828
+
829
+ // Recovery runs on fresh execution state (the old database is gone).
830
+ engine, err := goworkflows.New(db, deps)
831
+ if err != nil {
832
+ t.Fatalf("engine open for recovery failed: %v", err)
833
+ }
834
+ if err := engine.Start(context.Background()); err != nil {
835
+ t.Fatal(err)
836
+ }
837
+ defer func() {
838
+ c, cc := context.WithTimeout(context.Background(), 30*time.Second)
839
+ defer cc()
840
+ _ = engine.Shutdown(c)
841
+ }()
842
+
843
+ if err := recoverTickets(context.Background(), engine, sys, fr, linearWorkflow(false)); err != nil {
844
+ t.Fatalf("recoverTickets failed: %v", err)
845
+ }
846
+
847
+ // Fresh deterministic runs starting at the start edge target (coding),
848
+ // with fresh visit IDs that differ from the pre-loss visit.
849
+ for _, key := range []string{"PAY-101", "PAY-102"} {
850
+ rid := identity.NewRunID("payments", "basicFlow", key)
851
+ var rr run.Run
852
+ waitFor(t, 10*time.Second, func() bool {
853
+ var err error
854
+ rr, err = engine.GetRun(context.Background(), rid)
855
+ return err == nil && rr.CurrentNodeVisitID != ""
856
+ })
857
+ if rr.CurrentNode != "coding" {
858
+ t.Fatalf("%s recovered to node %q, want start-edge target coding", key, rr.CurrentNode)
859
+ }
860
+ if rr.CurrentNodeVisitID == preLossVisit {
861
+ t.Fatalf("%s reused the pre-loss nodeVisitID; recovery must generate fresh visit IDs", key)
862
+ }
863
+ rt, err := engine.GetNodeRuntime(context.Background(), rid, "coding")
864
+ if err != nil {
865
+ t.Fatalf("%s runtime: %v", key, err)
866
+ }
867
+ if rt.SessionID == "pre-loss-session" {
868
+ t.Fatalf("%s recovery reused pre-loss session ID", key)
869
+ }
870
+ if rt.TerminalID == preRuntime.TerminalID {
871
+ t.Fatalf("%s recovery reused pre-loss terminal ID %q", key, rt.TerminalID)
872
+ }
873
+ }
874
+ if log.count("inspectTerminal:") != inspectBeforeRecover {
875
+ t.Fatalf("recover used pre-loss direct terminal IDs: %v", log.all())
876
+ }
877
+ if log.count("closeTerminals:") == 0 {
878
+ t.Fatalf("recover did not use documented external terminal cleanup: %v", log.all())
879
+ }
880
+ // Canceled parent skipped.
881
+ if _, err := engine.FindRunByTicket(context.Background(), "PAY-103"); err == nil {
882
+ t.Fatal("recovery created a run for a cancellation-marked parent")
883
+ }
884
+ // Exactly the two active labeled non-canceled parents recovered.
885
+ runs, err := engine.ListRuns(context.Background(), run.Filter{Repo: "payments"})
886
+ if err != nil {
887
+ t.Fatal(err)
888
+ }
889
+ if len(runs) != 2 {
890
+ t.Fatalf("recovery created %d runs, want exactly 2", len(runs))
891
+ }
892
+
893
+ // Surviving terminals closed, environments/workspaces preserved.
894
+ if log.count("closeTerminals:") == 0 {
895
+ t.Fatal("recovery never closed surviving run-owned terminals")
896
+ }
897
+ if len(fr.envs) == 0 {
898
+ t.Fatal("recovery removed runner environments; worktrees/code must be preserved")
899
+ }
900
+
901
+ // Existing mailbox found (PAY-101), missing one created (PAY-102). The
902
+ // pre-loss run and the recovery pass both legitimately find the seeded
903
+ // mailbox; what must hold is that it is found (>=1) and never recreated.
904
+ if log.count("foundMailbox:PAY-101:coding") < 1 {
905
+ t.Fatal("existing PAY-101 coding mailbox not found/reused")
906
+ }
907
+ if log.count("createMailbox:PAY-101:coding") != 0 {
908
+ t.Fatal("existing PAY-101 coding mailbox recreated")
909
+ }
910
+ if log.count("createMailbox:PAY-102:coding") != 1 {
911
+ t.Fatal("missing PAY-102 coding mailbox not created")
912
+ }
913
+
914
+ // Mailboxes reset to To Do; comments/labels preserved. The fresh durable
915
+ // run then legitimately re-applies the node status (In Progress), so the
916
+ // observable post-recovery status is either the reset To Do or the fresh
917
+ // run's In Progress — never a stale pre-loss value.
918
+ if len(sys.resets) != 2 {
919
+ t.Fatalf("ResetForRecovery calls = %v, want 2 (PAY-101, PAY-102)", sys.resets)
920
+ }
921
+ for _, key := range []string{"PAY-101-coding", "PAY-102-coding"} {
922
+ if got := sys.mailboxStatusOf(key); got != "To Do" && got != "In Progress" {
923
+ t.Fatalf("mailbox %s status = %q after recovery, want reset To Do or fresh-run In Progress", key, got)
924
+ }
925
+ }
926
+ // Parent workflow labels preserved (claim identity survives recovery).
927
+ for _, key := range []string{"PAY-101", "PAY-102"} {
928
+ p, ok := sys.parentByKey(key)
929
+ if !ok || len(p.WorkflowClaims) == 0 {
930
+ t.Fatalf("parent %s lost its wf label during recovery", key)
931
+ }
932
+ }
933
+ foundOld := false
934
+ for _, c := range sys.commentBodies("PAY-101-coding") {
935
+ if c.Body == "old summary" {
936
+ foundOld = true
937
+ }
938
+ }
939
+ if !foundOld {
940
+ t.Fatal("recovery dropped existing mailbox comments; must preserve them")
941
+ }
942
+ if len(sys.labelsFor("PAY-101-coding")) == 0 {
943
+ t.Fatal("recovery dropped mailbox wf label; must preserve it")
944
+ }
945
+ }
946
+
947
+ // --- 3.25: retention ---
948
+
949
+ func TestRetentionRemovesOldTerminalRunsKeepsOthers(t *testing.T) {
950
+ // The retention clock is driven by finished_at values inserted directly
951
+ // into relay_runs via SQL (the documented projection table), not by an
952
+ // engine-internal clock.
953
+ log := newEventLog()
954
+ sys := newFakeTaskSystem(log)
955
+ db := filepath.Join(t.TempDir(), "state.db")
956
+ deps := goworkflows.Dependencies{Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log)}
957
+ engine, err := goworkflows.New(db, deps)
958
+ if err != nil {
959
+ t.Fatal(err)
960
+ }
961
+ if err := engine.Start(context.Background()); err != nil {
962
+ t.Fatal(err)
963
+ }
964
+ defer func() {
965
+ c, cc := context.WithTimeout(context.Background(), 30*time.Second)
966
+ defer cc()
967
+ _ = engine.Shutdown(c)
968
+ }()
969
+
970
+ // Stop the engine, then INSERT already-aged terminal fixtures (finished_at
971
+ // 31 days back, past the default 30-day retention) directly into
972
+ // relay_runs — the settled retention clock seam (tasks.md 3.25).
973
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
974
+ _ = engine.Shutdown(ctx)
975
+ cancel()
976
+ oldCompleted := run.ID("payments/basicFlow/PAY-OLD1")
977
+ oldCanceled := run.ID("payments/basicFlow/PAY-OLD2")
978
+ insertOldTerminalRun(t, db, oldCompleted, run.StateCompleted, "PAY-OLD1", 31*24*time.Hour)
979
+ insertOldTerminalRun(t, db, oldCanceled, run.StateCanceled, "PAY-OLD2", 31*24*time.Hour)
980
+ // Nonterminal fixtures (finished_at NULL) in every other state must be
981
+ // retained regardless of age.
982
+ insertRelayRun(t, db, run.ID("payments/basicFlow/PAY-S1"), run.StateStarting, "PAY-S1")
983
+ insertRelayRun(t, db, run.ID("payments/basicFlow/PAY-R1"), run.StateRunning, "PAY-R1")
984
+ insertRelayRun(t, db, run.ID("payments/basicFlow/PAY-B1"), run.StateBlocked, "PAY-B1")
985
+ insertRelayRun(t, db, run.ID("payments/basicFlow/PAY-C1"), run.StateCanceling, "PAY-C1")
986
+
987
+ // Restart: the retention sweep runs once at startup (pre-poller window).
988
+ // Old terminal fixtures are removed; nonterminal fixtures are preserved.
989
+ engine2, err := goworkflows.New(db, deps)
990
+ if err != nil {
991
+ t.Fatal(err)
992
+ }
993
+ if err := engine2.Start(context.Background()); err != nil {
994
+ t.Fatal(err)
995
+ }
996
+ defer func() {
997
+ c, cc := context.WithTimeout(context.Background(), 30*time.Second)
998
+ defer cc()
999
+ _ = engine2.Shutdown(c)
1000
+ }()
1001
+
1002
+ if _, err := engine2.GetRun(context.Background(), oldCompleted); err == nil {
1003
+ t.Fatal("old completed run not removed by the startup retention sweep")
1004
+ }
1005
+ if _, err := engine2.GetRun(context.Background(), oldCanceled); err == nil {
1006
+ t.Fatal("old canceled run not removed by the startup retention sweep")
1007
+ }
1008
+ for _, kept := range []run.ID{
1009
+ "payments/basicFlow/PAY-S1", "payments/basicFlow/PAY-R1",
1010
+ "payments/basicFlow/PAY-B1", "payments/basicFlow/PAY-C1",
1011
+ } {
1012
+ if _, err := engine2.GetRun(context.Background(), kept); err != nil {
1013
+ t.Fatalf("nonterminal run %s removed by retention: %v", kept, err)
1014
+ }
1015
+ }
1016
+ }
1017
+
1018
+ // --- 3.26: projection (kept passing) ---
1019
+ func TestRunProjectionQueries(t *testing.T) {
1020
+ log := newEventLog()
1021
+ sys := newFakeTaskSystem(log)
1022
+ engine := newEngine(t, goworkflows.Dependencies{
1023
+ Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
1024
+ })
1025
+ rid, _ := startRun(engine, linearWorkflow(false))
1026
+
1027
+ r, err := engine.GetRun(context.Background(), rid)
1028
+ if err != nil || r.ID != rid {
1029
+ t.Fatalf("GetRun = %+v, %v", r, err)
1030
+ }
1031
+ r, err = engine.FindRunByTicket(context.Background(), "PAY-101")
1032
+ if err != nil || r.ID != rid {
1033
+ t.Fatalf("FindRunByTicket = %+v, %v", r, err)
1034
+ }
1035
+ runs, err := engine.ListRuns(context.Background(), run.Filter{Repo: "payments"})
1036
+ if err != nil || len(runs) != 1 {
1037
+ t.Fatalf("ListRuns = %+v, %v", runs, err)
1038
+ }
1039
+ active, err := engine.HasActiveWorkflow(context.Background(), "basicFlow")
1040
+ if err != nil || !active {
1041
+ t.Fatalf("HasActiveWorkflow = %v, %v", active, err)
1042
+ }
1043
+ active, err = engine.HasActiveRepo(context.Background(), "payments")
1044
+ if err != nil || !active {
1045
+ t.Fatalf("HasActiveRepo = %v, %v", active, err)
1046
+ }
1047
+ }
1048
+
1049
+ // insertOldTerminalRun inserts an already-aged terminal fixture row directly
1050
+ // into the relay_runs projection — the settled retention clock seam (tasks.md
1051
+ // 3.25: "by inserting old finished_at fixtures into relay_runs").
1052
+ func insertOldTerminalRun(t *testing.T, dbPath string, id run.ID, state run.State, ticketKey string, ago time.Duration) {
1053
+ t.Helper()
1054
+ db, err := sql.Open("sqlite", dbPath)
1055
+ if err != nil {
1056
+ t.Fatal(err)
1057
+ }
1058
+ defer db.Close()
1059
+ finished := time.Now().Add(-ago)
1060
+ res, err := db.Exec(
1061
+ `INSERT INTO relay_runs (id, repo, workflow, ticket_id, ticket_key, state, started_at, updated_at, finished_at)
1062
+ VALUES (?, 'payments', 'basicFlow', ?, ?, ?, ?, ?, ?)`,
1063
+ string(id), ticketKey, ticketKey, string(state), finished.Add(-time.Hour), finished, finished)
1064
+ if err != nil {
1065
+ t.Fatalf("insert aged terminal fixture: %v", err)
1066
+ }
1067
+ if n, _ := res.RowsAffected(); n != 1 {
1068
+ t.Fatalf("insert affected %d rows, want 1", n)
1069
+ }
1070
+ }
1071
+
1072
+ // insertRelayRun inserts a NON-terminal fixture row (finished_at NULL) so the
1073
+ // retention sweep must preserve it regardless of age.
1074
+ func insertRelayRun(t *testing.T, dbPath string, id run.ID, state run.State, ticketKey string) {
1075
+ t.Helper()
1076
+ db, err := sql.Open("sqlite", dbPath)
1077
+ if err != nil {
1078
+ t.Fatal(err)
1079
+ }
1080
+ defer db.Close()
1081
+ now := time.Now()
1082
+ res, err := db.Exec(
1083
+ `INSERT INTO relay_runs (id, repo, workflow, ticket_id, ticket_key, state, started_at, updated_at, finished_at)
1084
+ VALUES (?, 'payments', 'basicFlow', ?, ?, ?, ?, ?, NULL)`,
1085
+ string(id), ticketKey, ticketKey, string(state), now, now)
1086
+ if err != nil {
1087
+ t.Fatalf("insert nonterminal fixture: %v", err)
1088
+ }
1089
+ if n, _ := res.RowsAffected(); n != 1 {
1090
+ t.Fatalf("insert affected %d rows, want 1", n)
1091
+ }
1092
+ }