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,127 @@
1
+ package goworkflows_test
2
+
3
+ import (
4
+ "context"
5
+ "path/filepath"
6
+ "testing"
7
+ "time"
8
+
9
+ "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
10
+ "github.com/rajpopat27/relay-flow/internal/identity"
11
+ "github.com/rajpopat27/relay-flow/internal/run"
12
+ "github.com/rajpopat27/relay-flow/internal/task"
13
+ "github.com/rajpopat27/relay-flow/internal/workflow"
14
+ )
15
+
16
+ func TestPersistedRuntimeLoopAndRestart(t *testing.T) {
17
+ log := newEventLog()
18
+ sys := newFakeTaskSystem(log)
19
+ fr := newFakeRunner(log)
20
+ fh := newFakeHarness(log)
21
+ wf := workflow.Workflow{
22
+ Name: "loopFlow", Repos: []string{"payments"},
23
+ Nodes: map[string]workflow.Node{
24
+ "start": {OnSuccess: []workflow.Route{{Target: "implement"}}},
25
+ "implement": {Type: workflow.NodeAgent, Agent: "build", Description: "implement",
26
+ OnSuccess: []workflow.Route{{Target: "verify"}}, OnFailure: []workflow.Route{{Target: "verify"}}},
27
+ "verify": {Type: workflow.NodeAgent, Agent: "verify", Description: "verify",
28
+ OnSuccess: []workflow.Route{{Target: "end"}}, OnFailure: []workflow.Route{{Target: "implement"}}},
29
+ "end": {},
30
+ },
31
+ }
32
+ db := filepath.Join(t.TempDir(), "state.db")
33
+ policy := run.RuntimePolicy{KeepTerminalsAlive: true, KeepSessionsAlive: true}
34
+ deps := goworkflows.Dependencies{
35
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: fh, Runtime: &policy,
36
+ }
37
+ e1, err := goworkflows.New(db, deps)
38
+ if err != nil {
39
+ t.Fatal(err)
40
+ }
41
+ if err := e1.Start(context.Background()); err != nil {
42
+ t.Fatal(err)
43
+ }
44
+ rid := identity.NewRunID("payments", "loopFlow", "PAY-101")
45
+ start := run.Start{ID: rid, Repo: "payments", RepoPath: "/srv/payments", Workflow: wf, Ticket: task.TicketRef{ID: "1", Key: "PAY-101"}, Runtime: policy}
46
+ if _, err := e1.EnsureRun(context.Background(), start); err != nil {
47
+ t.Fatal(err)
48
+ }
49
+ waitFor(t, 10*time.Second, func() bool {
50
+ r, _ := e1.GetRun(context.Background(), rid)
51
+ return r.CurrentNode == "implement" && r.CurrentNodeVisitID != ""
52
+ })
53
+ first, _ := e1.GetRun(context.Background(), rid)
54
+ firstRT, _ := e1.GetNodeRuntime(context.Background(), rid, "implement")
55
+ if _, err := e1.RegisterNodeSession(context.Background(), run.NodeRuntimeRegistration{RunID: rid, Node: "implement", SessionID: "session-implement"}); err != nil {
56
+ t.Fatal(err)
57
+ }
58
+ firstReport := reportRequest(rid, "implement", loopReport(workflow.OutcomeSuccess, "verify"))
59
+ if _, err := e1.SubmitReport(context.Background(), firstReport); err != nil {
60
+ t.Fatal(err)
61
+ }
62
+ waitFor(t, 10*time.Second, func() bool { r, _ := e1.GetRun(context.Background(), rid); return r.CurrentNode == "verify" })
63
+ verify, _ := e1.GetRun(context.Background(), rid)
64
+ if _, err := e1.RegisterNodeSession(context.Background(), run.NodeRuntimeRegistration{RunID: rid, Node: "verify", SessionID: "session-verify"}); err != nil {
65
+ t.Fatal(err)
66
+ }
67
+ verifyRT, err := e1.GetNodeRuntime(context.Background(), rid, "verify")
68
+ if err != nil || verifyRT.SessionID != "session-verify" || verifyRT.NodeVisitID != verify.CurrentNodeVisitID {
69
+ t.Fatalf("verify runtime = %+v, %v", verifyRT, err)
70
+ }
71
+ if _, err := e1.SubmitReport(context.Background(), reportRequest(rid, "verify", loopReport(workflow.OutcomeFailure, "implement"))); err != nil {
72
+ t.Fatal(err)
73
+ }
74
+ waitFor(t, 10*time.Second, func() bool {
75
+ r, _ := e1.GetRun(context.Background(), rid)
76
+ return r.CurrentNode == "implement" && r.CurrentNodeVisitID != first.CurrentNodeVisitID
77
+ })
78
+ second, _ := e1.GetRun(context.Background(), rid)
79
+ secondRT, _ := e1.GetNodeRuntime(context.Background(), rid, "implement")
80
+ if secondRT.TerminalID != firstRT.TerminalID || secondRT.SessionID != "session-implement" || secondRT.NodeVisitID != second.CurrentNodeVisitID {
81
+ t.Fatalf("loop runtime = %+v; first=%+v", secondRT, firstRT)
82
+ }
83
+ staleAck, err := e1.SubmitReport(context.Background(), firstReport)
84
+ if err != nil || !staleAck.Duplicate {
85
+ t.Fatalf("stale ack=%+v err=%v", staleAck, err)
86
+ }
87
+ if log.count("findTerminal:") != 0 {
88
+ t.Fatalf("normal path used title discovery: %v", log.all())
89
+ }
90
+ if log.count("findSession:") != 0 {
91
+ t.Fatalf("normal path used session discovery: %v", log.all())
92
+ }
93
+
94
+ oldTerminal := secondRT.TerminalID
95
+ fr.killTerminals()
96
+ findTerminalBefore := log.count("findTerminal:")
97
+ findSessionBefore := log.count("findSession:")
98
+ e2 := restartEngine(t, db, deps, e1)
99
+ waitFor(t, 10*time.Second, func() bool {
100
+ r, _ := e2.GetRun(context.Background(), rid)
101
+ return r.CurrentNodeVisitID == second.CurrentNodeVisitID
102
+ })
103
+ if _, err := e2.EnsureRun(context.Background(), start); err != nil {
104
+ t.Fatal(err)
105
+ }
106
+ resumeEvent := "buildCommand:implement:" + string(second.CurrentNodeVisitID) + ":resume=session-implement"
107
+ waitFor(t, 10*time.Second, func() bool { return log.count(resumeEvent) == 1 })
108
+ after, _ := e2.GetNodeRuntime(context.Background(), rid, "implement")
109
+ if after.TerminalID == oldTerminal || after.SessionID != "session-implement" || after.NodeVisitID != second.CurrentNodeVisitID {
110
+ t.Fatalf("restart did not resume/replace direct runtime: old=%+v after=%+v", secondRT, after)
111
+ }
112
+ if log.count("findTerminal:") != findTerminalBefore || log.count("findSession:") != findSessionBefore {
113
+ t.Fatalf("restart used discovery: %v", log.all())
114
+ }
115
+ }
116
+
117
+ func loopReport(status workflow.Outcome, next string) workflow.Report {
118
+ r := successReport(next)
119
+ r.Status = status
120
+ if next != "end" {
121
+ r.Feedback.ReasonForNextStep = "route"
122
+ r.Feedback.RequiredActions = "act"
123
+ r.Feedback.RelevantContext = "context"
124
+ r.Feedback.ExpectedResult = "result"
125
+ }
126
+ return r
127
+ }
@@ -0,0 +1,486 @@
1
+ package goworkflows
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "errors"
7
+ "path/filepath"
8
+ "testing"
9
+ "time"
10
+
11
+ "github.com/rajpopat27/relay-flow/internal/harness"
12
+ "github.com/rajpopat27/relay-flow/internal/repo"
13
+ "github.com/rajpopat27/relay-flow/internal/run"
14
+ "github.com/rajpopat27/relay-flow/internal/runner"
15
+ "github.com/rajpopat27/relay-flow/internal/task"
16
+ "github.com/rajpopat27/relay-flow/internal/workflow"
17
+ )
18
+
19
+ func TestNodeRuntimeMigrationAndRestart(t *testing.T) {
20
+ ctx := context.Background()
21
+ path := filepath.Join(t.TempDir(), "state.db")
22
+ db := openProjectionDB(t, path)
23
+
24
+ // Simulate an existing database that predates relay_node_runtime.
25
+ if _, err := db.Exec(`CREATE TABLE relay_runs (
26
+ id TEXT PRIMARY KEY, repo TEXT NOT NULL, workflow TEXT NOT NULL,
27
+ ticket_id TEXT NOT NULL, ticket_key TEXT NOT NULL, state TEXT NOT NULL,
28
+ current_node TEXT, current_node_visit_id TEXT, last_error TEXT,
29
+ retry_error TEXT, retry_attempt INTEGER, next_retry_at DATETIME,
30
+ started_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, finished_at DATETIME
31
+ )`); err != nil {
32
+ t.Fatal(err)
33
+ }
34
+ p := &RunProjection{DB: db}
35
+ if err := p.migrate(); err != nil {
36
+ t.Fatalf("migrate existing database: %v", err)
37
+ }
38
+
39
+ id := run.ID("payments/basic/PAY-101")
40
+ if err := p.insertStart(ctx, run.Start{
41
+ ID: id, Repo: "payments", Workflow: workflow.Workflow{Name: "basic"},
42
+ Ticket: task.TicketRef{ID: "1", Key: "PAY-101"},
43
+ }, time.Now().UTC()); err != nil {
44
+ t.Fatal(err)
45
+ }
46
+ firstVisit := run.NodeVisitID("visit-1")
47
+ if err := p.updateNode(ctx, id, run.StateRunning, "implement", firstVisit); err != nil {
48
+ t.Fatal(err)
49
+ }
50
+ if err := p.updateNodeRuntime(ctx, NodeRuntime{
51
+ RunID: id, Node: "implement", TerminalID: "term-1", SessionID: "session-1", NodeVisitID: firstVisit,
52
+ }); err != nil {
53
+ t.Fatal(err)
54
+ }
55
+
56
+ // Moving to another node retains implement's runtime row.
57
+ if err := p.updateNode(ctx, id, run.StateRunning, "verify", "visit-2"); err != nil {
58
+ t.Fatal(err)
59
+ }
60
+ implement, err := p.getNodeRuntime(ctx, id, "implement")
61
+ if err != nil {
62
+ t.Fatal(err)
63
+ }
64
+ if implement.TerminalID != "term-1" || implement.SessionID != "session-1" || implement.NodeVisitID != firstVisit {
65
+ t.Fatalf("runtime changed across transition: %+v", implement)
66
+ }
67
+
68
+ // Revisiting updates only the visit ID, preserving reusable identities.
69
+ secondVisit := run.NodeVisitID("visit-3")
70
+ if err := p.updateNode(ctx, id, run.StateRunning, "implement", secondVisit); err != nil {
71
+ t.Fatal(err)
72
+ }
73
+ implement, err = p.getNodeRuntime(ctx, id, "implement")
74
+ if err != nil {
75
+ t.Fatal(err)
76
+ }
77
+ if implement.TerminalID != "term-1" || implement.SessionID != "session-1" || implement.NodeVisitID != secondVisit {
78
+ t.Fatalf("runtime not preserved on revisit: %+v", implement)
79
+ }
80
+ if implement.UpdatedAt.IsZero() {
81
+ t.Fatal("runtime updated_at is zero")
82
+ }
83
+
84
+ if err := db.Close(); err != nil {
85
+ t.Fatal(err)
86
+ }
87
+ db = openProjectionDB(t, path)
88
+ defer db.Close()
89
+ p = &RunProjection{DB: db}
90
+ if err := p.migrate(); err != nil {
91
+ t.Fatalf("migrate after restart: %v", err)
92
+ }
93
+ implement, err = p.getNodeRuntime(ctx, id, "implement")
94
+ if err != nil {
95
+ t.Fatalf("query runtime after restart: %v", err)
96
+ }
97
+ if implement.TerminalID != "term-1" || implement.SessionID != "session-1" || implement.NodeVisitID != secondVisit {
98
+ t.Fatalf("runtime changed across restart: %+v", implement)
99
+ }
100
+ }
101
+
102
+ func TestNodeRuntimeRemovedOnlyByRetention(t *testing.T) {
103
+ ctx := context.Background()
104
+ db := openProjectionDB(t, filepath.Join(t.TempDir(), "state.db"))
105
+ defer db.Close()
106
+ p := &RunProjection{DB: db}
107
+ if err := p.migrate(); err != nil {
108
+ t.Fatal(err)
109
+ }
110
+
111
+ id := run.ID("payments/basic/PAY-OLD")
112
+ started := time.Now().UTC().Add(-32 * 24 * time.Hour)
113
+ if err := p.insertStart(ctx, run.Start{
114
+ ID: id, Repo: "payments", Workflow: workflow.Workflow{Name: "basic"},
115
+ Ticket: task.TicketRef{ID: "old", Key: "PAY-OLD"},
116
+ }, started); err != nil {
117
+ t.Fatal(err)
118
+ }
119
+ if err := p.updateNode(ctx, id, run.StateRunning, "implement", "visit-old"); err != nil {
120
+ t.Fatal(err)
121
+ }
122
+ if err := p.updateNodeRuntime(ctx, NodeRuntime{
123
+ RunID: id, Node: "implement", TerminalID: "term-old", SessionID: "session-old", NodeVisitID: "visit-old",
124
+ }); err != nil {
125
+ t.Fatal(err)
126
+ }
127
+ finished := time.Now().UTC().Add(-31 * 24 * time.Hour)
128
+ if err := p.updateState(ctx, id, run.StateCompleted, "", &finished); err != nil {
129
+ t.Fatal(err)
130
+ }
131
+ if _, err := p.getNodeRuntime(ctx, id, "implement"); err != nil {
132
+ t.Fatalf("completion removed runtime before retention: %v", err)
133
+ }
134
+
135
+ ids, err := p.sweepRetention(ctx, time.Now().UTC().Add(-30*24*time.Hour))
136
+ if err != nil {
137
+ t.Fatal(err)
138
+ }
139
+ if len(ids) != 1 || ids[0] != string(id) {
140
+ t.Fatalf("retained ids = %v, want [%s]", ids, id)
141
+ }
142
+ if _, err := p.getNodeRuntime(ctx, id, "implement"); !errors.Is(err, errNodeRuntimeNotFound) {
143
+ t.Fatalf("runtime after retention error = %v, want not found", err)
144
+ }
145
+ }
146
+
147
+ func TestNodeRuntimeSessionRegistrationKeepsOldSessionBoundToOldVisit(t *testing.T) {
148
+ ctx := context.Background()
149
+ db := openProjectionDB(t, filepath.Join(t.TempDir(), "state.db"))
150
+ defer db.Close()
151
+ p := &RunProjection{DB: db}
152
+ if err := p.migrate(); err != nil {
153
+ t.Fatal(err)
154
+ }
155
+ id := run.ID("payments/basic/PAY-101")
156
+ if err := p.insertStart(ctx, run.Start{
157
+ ID: id, Repo: "payments", Workflow: workflow.Workflow{Name: "basic"},
158
+ Ticket: task.TicketRef{ID: "1", Key: "PAY-101"},
159
+ }, time.Now().UTC()); err != nil {
160
+ t.Fatal(err)
161
+ }
162
+ if err := p.updateNodeRuntimeVisit(ctx, id, "implement", "visit-current"); err != nil {
163
+ t.Fatal(err)
164
+ }
165
+
166
+ accepted, err := p.registerNodeSession(ctx, run.NodeRuntimeRegistration{
167
+ RunID: id, Node: "implement", SessionID: "session-current",
168
+ })
169
+ if err != nil || !accepted {
170
+ t.Fatalf("current registration = %v, %v; want accepted", accepted, err)
171
+ }
172
+ if err := p.updateNodeRuntimeVisit(ctx, id, "implement", "visit-next"); err != nil {
173
+ t.Fatal(err)
174
+ }
175
+ accepted, err = p.registerNodeSession(ctx, run.NodeRuntimeRegistration{
176
+ RunID: id, Node: "implement", SessionID: "session-current",
177
+ })
178
+ if err != nil || accepted {
179
+ t.Fatalf("old session registration = %v, %v; want rejected", accepted, err)
180
+ }
181
+ rt, err := p.getNodeRuntime(ctx, id, "implement")
182
+ if err != nil {
183
+ t.Fatal(err)
184
+ }
185
+ if rt.SessionID != "session-current" || rt.NodeVisitID != "visit-next" {
186
+ t.Fatalf("old session changed current visit binding: %+v", rt)
187
+ }
188
+ }
189
+
190
+ func TestEnsureNodeRuntimeUsesDirectIDsAndFallsBackFresh(t *testing.T) {
191
+ ctx := context.Background()
192
+ fr := &runtimeTestRunner{createErr: runner.ErrSessionUnavailable}
193
+ fh := &runtimeTestHarness{}
194
+ db := openProjectionDB(t, filepath.Join(t.TempDir(), "state.db"))
195
+ defer db.Close()
196
+ p := &RunProjection{DB: db}
197
+ if err := p.migrate(); err != nil {
198
+ t.Fatal(err)
199
+ }
200
+ id := run.ID("payments/basic/PAY-101")
201
+ if err := p.insertStart(ctx, run.Start{
202
+ ID: id, Repo: "payments", Workflow: workflow.Workflow{Name: "basic"},
203
+ Ticket: task.TicketRef{ID: "1", Key: "PAY-101"},
204
+ }, time.Now().UTC()); err != nil {
205
+ t.Fatal(err)
206
+ }
207
+ if err := p.updateNodeRuntime(ctx, NodeRuntime{
208
+ RunID: id, Node: "implement", TerminalID: "dead-term", SessionID: "dead-session", NodeVisitID: "visit-2",
209
+ }); err != nil {
210
+ t.Fatal(err)
211
+ }
212
+ a := &Activities{Runner: fr, Harness: fh, Runs: p}
213
+ nw := run.NodeWork{Work: run.Work{RunID: id, Repo: "payments", Workflow: "basic", Parent: task.TicketRef{Key: "PAY-101"}}, Node: "implement", NodeVisitID: "visit-2"}
214
+ spec := harness.LaunchSpec{RunID: id, NodeVisitID: "visit-2", RepoName: "payments", Workflow: "basic", Ticket: "PAY-101", Node: "implement", Agent: "build", Title: "PAY-101:implement", Prompt: "work", NudgePrompt: "custom instructions"}
215
+ if err := a.EnsureNodeRuntime(ctx, nw, "/srv/payments", spec, NodeRuntime{NodeVisitID: "visit-2"}); err != nil {
216
+ t.Fatal(err)
217
+ }
218
+ rt, err := p.getNodeRuntime(ctx, id, "implement")
219
+ if err != nil {
220
+ t.Fatal(err)
221
+ }
222
+ if rt.TerminalID == "" || rt.TerminalID == "dead-term" || rt.SessionID != "" {
223
+ t.Fatalf("failed direct IDs not replaced atomically: %+v", rt)
224
+ }
225
+ if fr.findCalls != 0 || fh.buildCalls != 2 || fr.createCalls != 2 {
226
+ t.Fatalf("fallback used discovery or wrong launch count: find=%d build=%d create=%d", fr.findCalls, fh.buildCalls, fr.createCalls)
227
+ }
228
+ for _, prompt := range fh.prompts {
229
+ if prompt != "work" {
230
+ t.Fatalf("same-visit relaunch prompt = %q, want standard prompt only", prompt)
231
+ }
232
+ }
233
+ }
234
+
235
+ func TestEnsureNodeRuntimeInitialLaunchAppendsCustomInstructions(t *testing.T) {
236
+ ctx := context.Background()
237
+ db := openProjectionDB(t, filepath.Join(t.TempDir(), "state.db"))
238
+ defer db.Close()
239
+ p := &RunProjection{DB: db}
240
+ if err := p.migrate(); err != nil {
241
+ t.Fatal(err)
242
+ }
243
+ id := run.ID("payments/basic/PAY-100")
244
+ if err := p.insertStart(ctx, run.Start{ID: id, Repo: "payments", Workflow: workflow.Workflow{Name: "basic"}, Ticket: task.TicketRef{ID: "0", Key: "PAY-100"}}, time.Now().UTC()); err != nil {
245
+ t.Fatal(err)
246
+ }
247
+ if err := p.updateNodeRuntimeVisit(ctx, id, "implement", "visit-first"); err != nil {
248
+ t.Fatal(err)
249
+ }
250
+ fh := &runtimeTestHarness{}
251
+ a := &Activities{Runner: &runtimeTestRunner{}, Harness: fh, Runs: p}
252
+ nw := run.NodeWork{Work: run.Work{RunID: id}, Node: "implement", NodeVisitID: "visit-first"}
253
+ spec := harness.LaunchSpec{RunID: id, NodeVisitID: "visit-first", Node: "implement", Agent: "build", Prompt: "standard prompt", NudgePrompt: "custom instructions"}
254
+ if err := a.EnsureNodeRuntime(ctx, nw, "", spec, NodeRuntime{}); err != nil {
255
+ t.Fatal(err)
256
+ }
257
+ if len(fh.prompts) != 1 || fh.prompts[0] != "standard prompt\n\ncustom instructions" {
258
+ t.Fatalf("initial prompt = %q", fh.prompts)
259
+ }
260
+ }
261
+
262
+ func TestEnsureNodeRuntimeSendFailureClosesLiveTerminal(t *testing.T) {
263
+ ctx := context.Background()
264
+ db := openProjectionDB(t, filepath.Join(t.TempDir(), "state.db"))
265
+ defer db.Close()
266
+ p := &RunProjection{DB: db}
267
+ if err := p.migrate(); err != nil {
268
+ t.Fatal(err)
269
+ }
270
+ id := run.ID("payments/basic/PAY-102")
271
+ if err := p.insertStart(ctx, run.Start{ID: id, Repo: "payments", Workflow: workflow.Workflow{Name: "basic"}, Ticket: task.TicketRef{ID: "2", Key: "PAY-102"}}, time.Now().UTC()); err != nil {
272
+ t.Fatal(err)
273
+ }
274
+ if err := p.updateNodeRuntime(ctx, NodeRuntime{RunID: id, Node: "implement", TerminalID: "live-old", SessionID: "session-old", NodeVisitID: "visit-old"}); err != nil {
275
+ t.Fatal(err)
276
+ }
277
+ if err := p.updateNodeRuntimeVisit(ctx, id, "implement", "visit-new"); err != nil {
278
+ t.Fatal(err)
279
+ }
280
+ fr := &runtimeTestRunner{live: true, sendErr: errors.New("send failed")}
281
+ fh := &runtimeTestHarness{}
282
+ a := &Activities{Runner: fr, Harness: fh, Runs: p}
283
+ nw := run.NodeWork{Work: run.Work{RunID: id, Repo: "payments", Workflow: "basic", Parent: task.TicketRef{Key: "PAY-102"}}, Node: "implement", NodeVisitID: "visit-new", Mailbox: task.Mailbox{Key: "PAY-234", Node: "implement"}}
284
+ spec := harness.LaunchSpec{RunID: id, NodeVisitID: "visit-new", Node: "implement", Agent: "build", Title: "PAY-102:implement", Prompt: "work", NudgePrompt: "Read the latest review feedback."}
285
+ if err := a.EnsureNodeRuntime(ctx, nw, "/srv/payments", spec, NodeRuntime{RunID: id, Node: "implement", TerminalID: "live-old", SessionID: "session-old", NodeVisitID: "visit-old"}); err != nil {
286
+ t.Fatal(err)
287
+ }
288
+ if fr.closeCalls != 1 || fr.closedIDs[0] != "live-old" {
289
+ t.Fatalf("old live terminal not closed before replacement: %+v", fr.closedIDs)
290
+ }
291
+ if len(fr.sentTexts) != 1 || fr.sentTexts[0] != "New feedback was added to the comments section of your mailbox subtask PAY-234. Read it.\n\nRead the latest review feedback." {
292
+ t.Fatalf("live revisit prompt = %q", fr.sentTexts)
293
+ }
294
+ if len(fh.prompts) != 1 || fh.prompts[0] != "work\n\nRead the latest review feedback." {
295
+ t.Fatalf("revisit replacement prompt = %q", fh.prompts)
296
+ }
297
+ rt, _ := p.getNodeRuntime(ctx, id, "implement")
298
+ if rt.TerminalID == "live-old" || rt.SessionID != "" {
299
+ t.Fatalf("send failure did not replace IDs: %+v", rt)
300
+ }
301
+ }
302
+
303
+ func TestEnsureNodeRuntimeSameVisitSendsNothing(t *testing.T) {
304
+ ctx := context.Background()
305
+ db := openProjectionDB(t, filepath.Join(t.TempDir(), "state.db"))
306
+ defer db.Close()
307
+ p := &RunProjection{DB: db}
308
+ if err := p.migrate(); err != nil {
309
+ t.Fatal(err)
310
+ }
311
+ id := run.ID("payments/basic/PAY-103")
312
+ if err := p.insertStart(ctx, run.Start{ID: id, Repo: "payments", Workflow: workflow.Workflow{Name: "basic"}, Ticket: task.TicketRef{ID: "3", Key: "PAY-103"}}, time.Now().UTC()); err != nil {
313
+ t.Fatal(err)
314
+ }
315
+ if err := p.updateNodeRuntime(ctx, NodeRuntime{RunID: id, Node: "implement", TerminalID: "live", SessionID: "session", NodeVisitID: "visit"}); err != nil {
316
+ t.Fatal(err)
317
+ }
318
+ fr := &runtimeTestRunner{live: true}
319
+ a := &Activities{Runner: fr, Harness: &runtimeTestHarness{}, Runs: p}
320
+ nw := run.NodeWork{Work: run.Work{RunID: id}, Node: "implement", NodeVisitID: "visit"}
321
+ spec := harness.LaunchSpec{RunID: id, NodeVisitID: "visit", Node: "implement", Agent: "build", NudgePrompt: "custom instructions"}
322
+ if err := a.EnsureNodeRuntime(ctx, nw, "", spec, NodeRuntime{RunID: id, Node: "implement", TerminalID: "live", SessionID: "session", NodeVisitID: "visit"}); err != nil {
323
+ t.Fatal(err)
324
+ }
325
+ if len(fr.sentTexts) != 0 || fr.createCalls != 0 {
326
+ t.Fatalf("same visit sent=%q creates=%d", fr.sentTexts, fr.createCalls)
327
+ }
328
+ }
329
+
330
+ func TestEnsureNodeRuntimeRejectsStaleVisitWithoutLaunch(t *testing.T) {
331
+ ctx := context.Background()
332
+ db := openProjectionDB(t, filepath.Join(t.TempDir(), "state.db"))
333
+ defer db.Close()
334
+ p := &RunProjection{DB: db}
335
+ if err := p.migrate(); err != nil {
336
+ t.Fatal(err)
337
+ }
338
+ id := run.ID("payments/basic/PAY-103")
339
+ if err := p.insertStart(ctx, run.Start{ID: id, Repo: "payments", Workflow: workflow.Workflow{Name: "basic"}, Ticket: task.TicketRef{ID: "3", Key: "PAY-103"}}, time.Now().UTC()); err != nil {
340
+ t.Fatal(err)
341
+ }
342
+ if err := p.updateNodeRuntimeVisit(ctx, id, "implement", "visit-current"); err != nil {
343
+ t.Fatal(err)
344
+ }
345
+ fr := &runtimeTestRunner{}
346
+ a := &Activities{Runner: fr, Harness: &runtimeTestHarness{}, Runs: p}
347
+ nw := run.NodeWork{Work: run.Work{RunID: id}, Node: "implement", NodeVisitID: "visit-stale"}
348
+ err := a.EnsureNodeRuntime(ctx, nw, "", harness.LaunchSpec{NodeVisitID: "visit-stale", Node: "implement", Agent: "build"}, NodeRuntime{})
349
+ if err == nil || fr.createCalls != 0 {
350
+ t.Fatalf("stale visit err=%v createCalls=%d", err, fr.createCalls)
351
+ }
352
+ }
353
+
354
+ func TestRuntimeKeepPolicies(t *testing.T) {
355
+ ctx := context.Background()
356
+ for _, tc := range []struct {
357
+ name string
358
+ policy run.RuntimePolicy
359
+ wantTerminal string
360
+ wantSession string
361
+ wantCloseCalls int
362
+ }{
363
+ {name: "explicitly close terminal keep session", policy: run.RuntimePolicy{KeepSessionsAlive: true}, wantSession: "session-1", wantCloseCalls: 1},
364
+ {name: "explicitly discard both", policy: run.RuntimePolicy{}, wantCloseCalls: 1},
365
+ {name: "keep both", policy: run.RuntimePolicy{KeepTerminalsAlive: true, KeepSessionsAlive: true}, wantTerminal: "term-1", wantSession: "session-1"},
366
+ } {
367
+ t.Run(tc.name, func(t *testing.T) {
368
+ db := openProjectionDB(t, filepath.Join(t.TempDir(), "state.db"))
369
+ defer db.Close()
370
+ p := &RunProjection{DB: db}
371
+ if err := p.migrate(); err != nil {
372
+ t.Fatal(err)
373
+ }
374
+ id := run.ID("payments/basic/PAY-104")
375
+ if err := p.insertStart(ctx, run.Start{ID: id, Repo: "payments", Workflow: workflow.Workflow{Name: "basic"}, Ticket: task.TicketRef{ID: "4", Key: "PAY-104"}}, time.Now().UTC()); err != nil {
376
+ t.Fatal(err)
377
+ }
378
+ if err := p.updateNodeRuntime(ctx, NodeRuntime{RunID: id, Node: "implement", TerminalID: "term-1", SessionID: "session-1", NodeVisitID: "visit-1"}); err != nil {
379
+ t.Fatal(err)
380
+ }
381
+ fr := &runtimeTestRunner{}
382
+ a := &Activities{Runner: fr, Runs: p}
383
+ nw := run.NodeWork{Work: run.Work{RunID: id, Parent: task.TicketRef{Key: "PAY-104"}}, Node: "implement"}
384
+ if err := a.CheckpointNodeRuntime(ctx, nw, "", tc.policy); err != nil {
385
+ t.Fatal(err)
386
+ }
387
+ rt, err := p.getNodeRuntime(ctx, id, "implement")
388
+ if err != nil {
389
+ t.Fatal(err)
390
+ }
391
+ if rt.TerminalID != tc.wantTerminal || rt.SessionID != tc.wantSession || fr.closeCalls != tc.wantCloseCalls {
392
+ t.Fatalf("runtime=%+v closeCalls=%d", rt, fr.closeCalls)
393
+ }
394
+ })
395
+ }
396
+ }
397
+
398
+ func TestEngineRuntimePolicyDefaultsKeepBoth(t *testing.T) {
399
+ deps := Dependencies{
400
+ Repos: repo.NewRegistry(),
401
+ Runner: &runtimeTestRunner{},
402
+ Harness: &runtimeTestHarness{},
403
+ }
404
+ engine, err := New(filepath.Join(t.TempDir(), "state.db"), deps)
405
+ if err != nil {
406
+ t.Fatal(err)
407
+ }
408
+ defer engine.db.Close()
409
+ if !engine.runtime.KeepTerminalsAlive || !engine.runtime.KeepSessionsAlive {
410
+ t.Fatalf("default runtime policy = %+v, want both true", engine.runtime)
411
+ }
412
+ }
413
+
414
+ type runtimeTestRunner struct {
415
+ createErr error
416
+ findCalls int
417
+ createCalls int
418
+ live bool
419
+ sendErr error
420
+ sentTexts []string
421
+ closeCalls int
422
+ closedIDs []string
423
+ }
424
+
425
+ func (*runtimeTestRunner) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) {
426
+ return nil, nil
427
+ }
428
+ func (*runtimeTestRunner) ValidateRepo(context.Context, string, string) error { return nil }
429
+ func (*runtimeTestRunner) EnsureEnvironment(context.Context, runner.RunSpec) (runner.Environment, error) {
430
+ return runner.Environment{ID: "env"}, nil
431
+ }
432
+ func (r *runtimeTestRunner) InspectTerminal(_ context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
433
+ return terminal, r.live, nil
434
+ }
435
+ func (r *runtimeTestRunner) SendTerminal(_ context.Context, _ runner.Terminal, text string) error {
436
+ r.sentTexts = append(r.sentTexts, text)
437
+ return r.sendErr
438
+ }
439
+ func (r *runtimeTestRunner) CreateTerminal(context.Context, runner.Environment, string, runner.Command) (runner.Terminal, error) {
440
+ r.createCalls++
441
+ if r.createErr != nil {
442
+ err := r.createErr
443
+ r.createErr = nil
444
+ return runner.Terminal{}, err
445
+ }
446
+ return runner.Terminal{ID: "fresh-term"}, nil
447
+ }
448
+ func (r *runtimeTestRunner) FindTerminal(context.Context, runner.Environment, string) (runner.Terminal, bool, error) {
449
+ r.findCalls++
450
+ return runner.Terminal{}, false, nil
451
+ }
452
+ func (r *runtimeTestRunner) CloseTerminal(_ context.Context, terminal runner.Terminal) error {
453
+ r.closeCalls++
454
+ r.closedIDs = append(r.closedIDs, terminal.ID)
455
+ r.live = false
456
+ return nil
457
+ }
458
+ func (r *runtimeTestRunner) EnsureTerminal(ctx context.Context, env runner.Environment, title string, command runner.Command) (runner.Terminal, error) {
459
+ return r.CreateTerminal(ctx, env, title, command)
460
+ }
461
+ func (*runtimeTestRunner) CloseTerminals(context.Context, runner.RunSpec) error { return nil }
462
+ func (*runtimeTestRunner) CleanupRun(context.Context, runner.RunSpec) error { return nil }
463
+
464
+ type runtimeTestHarness struct {
465
+ buildCalls int
466
+ prompts []string
467
+ }
468
+
469
+ func (*runtimeTestHarness) ValidateAgent(context.Context, string, string) error { return nil }
470
+ func (*runtimeTestHarness) FindSession(context.Context, string, string) (harness.Session, bool, error) {
471
+ return harness.Session{}, false, nil
472
+ }
473
+ func (h *runtimeTestHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
474
+ h.buildCalls++
475
+ h.prompts = append(h.prompts, spec.Prompt)
476
+ return runner.Command{Executable: "opencode", Args: []string{spec.ResumeID}}, nil
477
+ }
478
+
479
+ func openProjectionDB(t *testing.T, path string) *sql.DB {
480
+ t.Helper()
481
+ db, err := sql.Open("sqlite", path)
482
+ if err != nil {
483
+ t.Fatal(err)
484
+ }
485
+ return db
486
+ }