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,517 @@
1
+ package goworkflows_test
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "sync"
7
+ "sync/atomic"
8
+ "time"
9
+
10
+ "github.com/rajpopat27/relay-flow/internal/config"
11
+ "github.com/rajpopat27/relay-flow/internal/harness"
12
+ "github.com/rajpopat27/relay-flow/internal/retry"
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
+ // Shared test fakes for the engine-level section-3 tests. Every external
20
+ // effect is appended to one ordered event log so tests can assert
21
+ // cross-primitive ordering (3.16), claim/cancellation sequencing (3.19),
22
+ // reconcile signals (3.21), and no-rollback (3.17).
23
+
24
+ type eventLog struct {
25
+ mu sync.Mutex
26
+ events []string
27
+ }
28
+
29
+ var testReportID atomic.Uint64
30
+
31
+ func reportRequest(id run.ID, node string, report workflow.Report) run.ReportRequest {
32
+ return run.ReportRequest{
33
+ RunID: id, Node: node,
34
+ ReportID: fmt.Sprintf("test:%d", testReportID.Add(1)),
35
+ Report: report,
36
+ }
37
+ }
38
+
39
+ func newEventLog() *eventLog { return &eventLog{} }
40
+
41
+ func (l *eventLog) add(e string) {
42
+ l.mu.Lock()
43
+ l.events = append(l.events, e)
44
+ l.mu.Unlock()
45
+ }
46
+
47
+ func (l *eventLog) all() []string {
48
+ l.mu.Lock()
49
+ defer l.mu.Unlock()
50
+ return append([]string{}, l.events...)
51
+ }
52
+
53
+ func (l *eventLog) count(prefix string) int {
54
+ l.mu.Lock()
55
+ defer l.mu.Unlock()
56
+ n := 0
57
+ for _, e := range l.events {
58
+ if len(e) >= len(prefix) && e[:len(prefix)] == prefix {
59
+ n++
60
+ }
61
+ }
62
+ return n
63
+ }
64
+
65
+ // --- task system ---
66
+
67
+ type fakeTaskSystem struct {
68
+ log *eventLog
69
+
70
+ mu sync.Mutex
71
+ mailboxes map[string]task.Mailbox
72
+ mailboxStatus map[string]string // mailbox key -> task status
73
+ labels map[string][]string // mailbox key -> labels
74
+ specs []task.MailboxSpec
75
+ comments []recordedComment
76
+ resets []string
77
+
78
+ // Failure/crash/slow injection — the fake IS the documented injection
79
+ // seam (allowed seam a). These make the fake adapter fail/stall so the
80
+ // engine's durable retry/crash behavior is observable through outcomes.
81
+ completeFail int // next N CompleteMailbox calls return transient error
82
+ completeConflict bool // CompleteMailbox returns retry.ConflictError
83
+ completeSlow time.Duration // CompleteMailbox sleeps (a running activity)
84
+ failComments bool // Comment returns transient error
85
+
86
+ // Recovery fixtures.
87
+ parentsToRecover []task.Ticket
88
+ canceledParents map[string]bool
89
+ }
90
+
91
+ type recordedComment struct {
92
+ Key string
93
+ Body string
94
+ Marker string
95
+ }
96
+
97
+ func newFakeTaskSystem(log *eventLog) *fakeTaskSystem {
98
+ return &fakeTaskSystem{
99
+ log: log,
100
+ mailboxes: map[string]task.Mailbox{},
101
+ mailboxStatus: map[string]string{},
102
+ labels: map[string][]string{},
103
+ }
104
+ }
105
+
106
+ func (s *fakeTaskSystem) Poll(context.Context) ([]task.Ticket, error) {
107
+ s.mu.Lock()
108
+ defer s.mu.Unlock()
109
+ return append([]task.Ticket{}, s.parentsToRecover...), nil
110
+ }
111
+
112
+ func (s *fakeTaskSystem) CompileFilter(config.RawValues) (func(task.Ticket) bool, error) {
113
+ return func(task.Ticket) bool { return true }, nil
114
+ }
115
+
116
+ func (s *fakeTaskSystem) Claim(_ context.Context, ref task.TicketRef, wf string) error {
117
+ s.log.add("claim:" + ref.Key + ":" + wf)
118
+ return nil
119
+ }
120
+
121
+ func (s *fakeTaskSystem) ValidateConfig(context.Context, config.RawValues, map[string]config.RawValues) error {
122
+ return nil
123
+ }
124
+
125
+ func (s *fakeTaskSystem) EnsureMailboxes(_ context.Context, parent task.TicketRef, wf string, specs []task.MailboxSpec) (map[string]task.Mailbox, error) {
126
+ s.log.add("ensureMailboxes:" + parent.Key)
127
+ s.mu.Lock()
128
+ defer s.mu.Unlock()
129
+ s.specs = append(s.specs, specs...)
130
+ out := map[string]task.Mailbox{}
131
+ for _, sp := range specs {
132
+ // Per-parent mailbox identity: parent key + node.
133
+ key := parent.Key + "/" + sp.Node
134
+ mb, ok := s.mailboxes[key]
135
+ if !ok {
136
+ mb = task.Mailbox{ID: "mb-" + parent.Key + "-" + sp.Node, Key: parent.Key + "-" + sp.Node, Node: sp.Node}
137
+ s.mailboxes[key] = mb
138
+ s.mailboxStatus[mb.Key] = "To Do"
139
+ s.labels[mb.Key] = []string{"wf:" + wf}
140
+ s.log.add("createMailbox:" + parent.Key + ":" + sp.Node)
141
+ } else {
142
+ s.log.add("foundMailbox:" + parent.Key + ":" + sp.Node)
143
+ }
144
+ out[sp.Node] = mb
145
+ }
146
+ return out, nil
147
+ }
148
+
149
+ func (s *fakeTaskSystem) ApplyTaskConfig(_ context.Context, target task.Target, cfg config.RawValues) error {
150
+ key := target.Parent.Key
151
+ if target.Mailbox != nil {
152
+ key = target.Mailbox.Key
153
+ s.mu.Lock()
154
+ s.mailboxStatus[target.Mailbox.Key] = "In Progress"
155
+ s.mu.Unlock()
156
+ }
157
+ s.log.add("applyTaskConfig:" + key)
158
+ return nil
159
+ }
160
+
161
+ func (s *fakeTaskSystem) CompleteMailbox(_ context.Context, mb task.Mailbox) error {
162
+ if s.completeSlow > 0 {
163
+ s.log.add("completeMailboxStart:" + mb.Key)
164
+ time.Sleep(s.completeSlow) // a running activity cancellation cannot interrupt
165
+ }
166
+ if s.completeFail > 0 {
167
+ s.completeFail--
168
+ s.log.add("completeMailboxFail:" + mb.Key)
169
+ return errTransient
170
+ }
171
+ if s.completeConflict {
172
+ s.log.add("completeMailboxConflict:" + mb.Key)
173
+ return retry.ConflictError(errConflict)
174
+ }
175
+ s.mu.Lock()
176
+ s.mailboxStatus[mb.Key] = "Done"
177
+ s.mu.Unlock()
178
+ s.log.add("completeMailbox:" + mb.Key)
179
+ return nil
180
+ }
181
+
182
+ func (s *fakeTaskSystem) HasComment(_ context.Context, target task.Target, marker string) (bool, error) {
183
+ s.mu.Lock()
184
+ defer s.mu.Unlock()
185
+ for _, c := range s.comments {
186
+ if c.Marker == marker {
187
+ return true, nil
188
+ }
189
+ }
190
+ // Recovery cancellation-marker check on a parent.
191
+ if s.canceledParents[target.Parent.Key] && hasSuffixStr(marker, ":cancellation") {
192
+ return true, nil
193
+ }
194
+ return false, nil
195
+ }
196
+
197
+ func (s *fakeTaskSystem) Comment(_ context.Context, target task.Target, body, marker string) error {
198
+ key := target.Parent.Key
199
+ if target.Mailbox != nil {
200
+ key = target.Mailbox.Key
201
+ }
202
+ if s.failComments {
203
+ s.log.add("commentFail:" + key)
204
+ return errTransient
205
+ }
206
+ s.mu.Lock()
207
+ s.comments = append(s.comments, recordedComment{Key: key, Body: body, Marker: marker})
208
+ s.mu.Unlock()
209
+ s.log.add("comment:" + key)
210
+ return nil
211
+ }
212
+
213
+ func (s *fakeTaskSystem) ResetForRecovery(_ context.Context, parent task.TicketRef, mbs []task.Mailbox, _ config.RawValues) error {
214
+ s.mu.Lock()
215
+ for _, mb := range mbs {
216
+ s.mailboxStatus[mb.Key] = "To Do"
217
+ }
218
+ s.resets = append(s.resets, parent.Key)
219
+ s.mu.Unlock()
220
+ s.log.add("resetForRecovery:" + parent.Key)
221
+ return nil
222
+ }
223
+
224
+ func (s *fakeTaskSystem) commentBodies(key string) []recordedComment {
225
+ s.mu.Lock()
226
+ defer s.mu.Unlock()
227
+ var out []recordedComment
228
+ for _, c := range s.comments {
229
+ if c.Key == key {
230
+ out = append(out, c)
231
+ }
232
+ }
233
+ return out
234
+ }
235
+
236
+ // mailboxesSnapshot returns a copy of the per-parent mailbox map, safe for
237
+ // tests to iterate without holding the lock.
238
+ func (s *fakeTaskSystem) mailboxesSnapshot() map[string]task.Mailbox {
239
+ s.mu.Lock()
240
+ defer s.mu.Unlock()
241
+ out := map[string]task.Mailbox{}
242
+ for k, v := range s.mailboxes {
243
+ out[k] = v
244
+ }
245
+ return out
246
+ }
247
+
248
+ func (s *fakeTaskSystem) labelsFor(key string) []string {
249
+ s.mu.Lock()
250
+ defer s.mu.Unlock()
251
+ return append([]string{}, s.labels[key]...)
252
+ }
253
+
254
+ func (s *fakeTaskSystem) mailboxStatusOf(key string) string {
255
+ s.mu.Lock()
256
+ defer s.mu.Unlock()
257
+ return s.mailboxStatus[key]
258
+ }
259
+
260
+ func (s *fakeTaskSystem) mailboxFor(parentKey, node string) (task.Mailbox, bool) {
261
+ s.mu.Lock()
262
+ defer s.mu.Unlock()
263
+ mb, ok := s.mailboxes[parentKey+"/"+node]
264
+ return mb, ok
265
+ }
266
+
267
+ func (s *fakeTaskSystem) setMailboxStatus(key, status string) {
268
+ s.mu.Lock()
269
+ s.mailboxStatus[key] = status
270
+ s.mu.Unlock()
271
+ }
272
+
273
+ // seedMailbox pre-populates an existing mailbox (with labels) for recovery
274
+ // and reuse tests.
275
+ func (s *fakeTaskSystem) seedMailbox(parentKey string, mb task.Mailbox, labels []string) {
276
+ s.mu.Lock()
277
+ defer s.mu.Unlock()
278
+ s.mailboxes[parentKey+"/"+mb.Node] = mb
279
+ s.mailboxStatus[mb.Key] = "To Do"
280
+ s.labels[mb.Key] = append([]string{}, labels...)
281
+ }
282
+
283
+ // parentByKey returns a pollable parent by key (for label assertions).
284
+ func (s *fakeTaskSystem) parentByKey(key string) (task.Ticket, bool) {
285
+ s.mu.Lock()
286
+ defer s.mu.Unlock()
287
+ for _, p := range s.parentsToRecover {
288
+ if p.Key == key {
289
+ return p, true
290
+ }
291
+ }
292
+ return task.Ticket{}, false
293
+ }
294
+
295
+ // --- runner ---
296
+
297
+ type fakeRunner struct {
298
+ log *eventLog
299
+
300
+ mu sync.Mutex
301
+ envs map[string]runner.Environment
302
+ terminals map[string]*fakeTerminal // envID/title
303
+ cleaned []string
304
+ closedRun []string
305
+ createErr error
306
+ nextID int
307
+ }
308
+
309
+ type fakeTerminal struct {
310
+ term runner.Terminal
311
+ live bool
312
+ title string
313
+ }
314
+
315
+ func newFakeRunner(log *eventLog) *fakeRunner {
316
+ return &fakeRunner{
317
+ log: log,
318
+ envs: map[string]runner.Environment{},
319
+ terminals: map[string]*fakeTerminal{},
320
+ }
321
+ }
322
+
323
+ func (f *fakeRunner) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) {
324
+ return []runner.RepoCandidate{{Name: "payments", Path: "/srv/payments"}}, nil
325
+ }
326
+ func (f *fakeRunner) ValidateRepo(context.Context, string, string) error { return nil }
327
+
328
+ func (f *fakeRunner) EnsureEnvironment(_ context.Context, spec runner.RunSpec) (runner.Environment, error) {
329
+ f.mu.Lock()
330
+ defer f.mu.Unlock()
331
+ if e, ok := f.envs[string(spec.RunID)]; ok {
332
+ return e, nil
333
+ }
334
+ e := runner.Environment{ID: "env-" + string(spec.RunID), Path: spec.RepoPath}
335
+ f.envs[string(spec.RunID)] = e
336
+ f.log.add("ensureEnvironment:" + string(spec.RunID))
337
+ return e, nil
338
+ }
339
+
340
+ func (f *fakeRunner) FindTerminal(_ context.Context, env runner.Environment, title string) (runner.Terminal, bool, error) {
341
+ f.mu.Lock()
342
+ defer f.mu.Unlock()
343
+ f.log.add("findTerminal:" + title)
344
+ ft, ok := f.terminals[env.ID+"/"+title]
345
+ if !ok || !ft.live {
346
+ return runner.Terminal{}, false, nil
347
+ }
348
+ return ft.term, true, nil
349
+ }
350
+
351
+ func (f *fakeRunner) InspectTerminal(_ context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
352
+ f.mu.Lock()
353
+ defer f.mu.Unlock()
354
+ f.log.add("inspectTerminal:" + terminal.ID)
355
+ for _, ft := range f.terminals {
356
+ if ft.term.ID == terminal.ID && ft.live {
357
+ return ft.term, true, nil
358
+ }
359
+ }
360
+ return runner.Terminal{}, false, nil
361
+ }
362
+
363
+ func (f *fakeRunner) SendTerminal(_ context.Context, terminal runner.Terminal, text string) error {
364
+ f.log.add("sendTerminal:" + terminal.ID + ":" + text)
365
+ return nil
366
+ }
367
+
368
+ func (f *fakeRunner) CreateTerminal(ctx context.Context, env runner.Environment, title string, command runner.Command) (runner.Terminal, error) {
369
+ f.mu.Lock()
370
+ defer f.mu.Unlock()
371
+ if f.createErr != nil {
372
+ err := f.createErr
373
+ f.createErr = nil
374
+ return runner.Terminal{}, err
375
+ }
376
+ f.nextID++
377
+ t := runner.Terminal{ID: fmt.Sprintf("t-%d-%s", f.nextID, title), Title: title}
378
+ f.terminals[env.ID+"/"+title] = &fakeTerminal{term: t, live: true, title: title}
379
+ f.log.add("ensureTerminal:" + title)
380
+ f.log.add("createTerminal:" + title + ":" + command.Executable)
381
+ return t, nil
382
+ }
383
+
384
+ func (f *fakeRunner) EnsureTerminal(_ context.Context, env runner.Environment, title string, _ runner.Command) (runner.Terminal, error) {
385
+ f.mu.Lock()
386
+ defer f.mu.Unlock()
387
+ key := env.ID + "/" + title
388
+ if ft, ok := f.terminals[key]; ok && ft.live {
389
+ return ft.term, nil
390
+ }
391
+ t := runner.Terminal{ID: "t-" + key, Title: title}
392
+ f.terminals[key] = &fakeTerminal{term: t, live: true, title: title}
393
+ f.log.add("ensureTerminal:" + title)
394
+ return t, nil
395
+ }
396
+
397
+ func (f *fakeRunner) CloseTerminal(_ context.Context, t runner.Terminal) error {
398
+ f.mu.Lock()
399
+ defer f.mu.Unlock()
400
+ for _, ft := range f.terminals {
401
+ if ft.term.ID == t.ID {
402
+ ft.live = false
403
+ }
404
+ }
405
+ f.log.add("closeTerminal:" + t.Title)
406
+ return nil
407
+ }
408
+
409
+ func (f *fakeRunner) CloseTerminals(_ context.Context, spec runner.RunSpec) error {
410
+ f.mu.Lock()
411
+ defer f.mu.Unlock()
412
+ prefix := "env-" + string(spec.RunID) + "/"
413
+ for k, ft := range f.terminals {
414
+ if len(k) >= len(prefix) && k[:len(prefix)] == prefix {
415
+ ft.live = false
416
+ f.log.add("closeTerminals:" + ft.title)
417
+ }
418
+ }
419
+ f.closedRun = append(f.closedRun, string(spec.RunID))
420
+ // Environment/workspace preserved.
421
+ return nil
422
+ }
423
+
424
+ func (f *fakeRunner) CleanupRun(_ context.Context, spec runner.RunSpec) error {
425
+ f.mu.Lock()
426
+ defer f.mu.Unlock()
427
+ prefix := "env-" + string(spec.RunID) + "/"
428
+ for k := range f.terminals {
429
+ if len(k) >= len(prefix) && k[:len(prefix)] == prefix {
430
+ delete(f.terminals, k)
431
+ }
432
+ }
433
+ delete(f.envs, string(spec.RunID))
434
+ f.cleaned = append(f.cleaned, string(spec.RunID))
435
+ f.log.add("cleanupRun:" + string(spec.RunID))
436
+ return nil
437
+ }
438
+
439
+ func (f *fakeRunner) liveTerminals() int {
440
+ f.mu.Lock()
441
+ defer f.mu.Unlock()
442
+ n := 0
443
+ for _, ft := range f.terminals {
444
+ if ft.live {
445
+ n++
446
+ }
447
+ }
448
+ return n
449
+ }
450
+
451
+ // killTerminals marks every terminal unusable (simulates terminal death for
452
+ // reconcile tests).
453
+ func (f *fakeRunner) killTerminals() {
454
+ f.mu.Lock()
455
+ defer f.mu.Unlock()
456
+ for _, ft := range f.terminals {
457
+ ft.live = false
458
+ }
459
+ }
460
+
461
+ // --- harness ---
462
+
463
+ type fakeHarness struct {
464
+ log *eventLog
465
+
466
+ mu sync.Mutex
467
+ validated []string
468
+ sessions map[string]harness.Session
469
+ reconcileNudge int // nudges sent to idle live HITL sessions (must stay 0)
470
+ }
471
+
472
+ func newFakeHarness(log *eventLog) *fakeHarness {
473
+ return &fakeHarness{log: log, sessions: map[string]harness.Session{}}
474
+ }
475
+
476
+ func (f *fakeHarness) ValidateAgent(_ context.Context, _, agent string) error {
477
+ f.mu.Lock()
478
+ f.validated = append(f.validated, agent)
479
+ f.mu.Unlock()
480
+ f.log.add("validateAgent:" + agent)
481
+ return nil
482
+ }
483
+
484
+ func (f *fakeHarness) FindSession(_ context.Context, _, title string) (harness.Session, bool, error) {
485
+ f.mu.Lock()
486
+ defer f.mu.Unlock()
487
+ f.log.add("findSession:" + title)
488
+ s, ok := f.sessions[title]
489
+ return s, ok, nil
490
+ }
491
+
492
+ func (f *fakeHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
493
+ f.log.add("buildCommand:" + spec.Node + ":" + string(spec.NodeVisitID) + ":resume=" + spec.ResumeID)
494
+ return runner.Command{Executable: "opencode"}, nil
495
+ }
496
+
497
+ func hasPrefix(s, prefix string) bool {
498
+ return len(s) >= len(prefix) && s[:len(prefix)] == prefix
499
+ }
500
+
501
+ func hasSuffixStr(s, suffix string) bool {
502
+ return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
503
+ }
504
+
505
+ // transientError is a Kind=Transient failure.
506
+ type transientError struct{ msg string }
507
+
508
+ func (e *transientError) Error() string { return e.msg }
509
+
510
+ var errTransient = &transientError{msg: "jira unavailable"}
511
+
512
+ // conflictError is wrapped by retry.ConflictError for Kind=Conflict.
513
+ type conflictError struct{ msg string }
514
+
515
+ func (e *conflictError) Error() string { return e.msg }
516
+
517
+ var errConflict = &conflictError{msg: "human moved mailbox"}