relay-flow 0.3.9-alpha → 0.3.10-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 (46) hide show
  1. package/README.md +27 -19
  2. package/cmd/relay-flow/commands_test.go +440 -21
  3. package/cmd/relay-flow/main.go +203 -37
  4. package/cmd/relay-flow/observability_test.go +11 -0
  5. package/cmd/relay-flow/onboarding.go +1 -1
  6. package/cmd/relay-flow/onboarding_test.go +1 -1
  7. package/cmd/relay-flow/scenario_test.go +26 -10
  8. package/cmd/relay-flow/serve.go +27 -1
  9. package/internal/execution/goworkflows/fakes_test.go +20 -0
  10. package/internal/execution/goworkflows/interpreter.go +1 -0
  11. package/internal/execution/goworkflows/ownership_test.go +259 -0
  12. package/internal/execution/goworkflows/recovery_test.go +81 -1
  13. package/internal/execution/temporal/interpreter.go +2 -1
  14. package/internal/execution/temporal/recovery.go +68 -36
  15. package/internal/harness/harness.go +1 -0
  16. package/internal/harness/opencode/opencode.go +5 -0
  17. package/internal/harness/opencode/opencode_test.go +17 -1
  18. package/internal/harness/opencode/repo_setup.go +1 -1
  19. package/internal/harness/pi/pi.go +6 -0
  20. package/internal/harness/pi/task_env_test.go +16 -0
  21. package/internal/recover/recover.go +30 -1
  22. package/internal/repo/binding_test.go +63 -0
  23. package/internal/repo/repo.go +47 -6
  24. package/internal/router/router.go +39 -5
  25. package/internal/router/router_test.go +67 -1
  26. package/internal/run/manager.go +90 -3
  27. package/internal/run/run_manager_test.go +74 -0
  28. package/internal/server/api_test.go +13 -0
  29. package/internal/server/client.go +7 -0
  30. package/internal/server/fixture_test.go +5 -0
  31. package/internal/server/server.go +32 -3
  32. package/internal/task/beads/beads.go +201 -3
  33. package/internal/task/beads/beads_test.go +172 -0
  34. package/internal/task/jira/effects_test.go +86 -0
  35. package/internal/task/jira/filters_test.go +103 -0
  36. package/internal/task/jira/helpers_test.go +11 -2
  37. package/internal/task/jira/jira.go +228 -4
  38. package/internal/task/jira/normalize.go +30 -15
  39. package/internal/task/jira/rest/client.go +17 -2
  40. package/internal/task/jira/transition_defaults_test.go +3 -1
  41. package/internal/task/task.go +66 -0
  42. package/internal/workflow/report.go +3 -0
  43. package/internal/workflow/report_test.go +16 -0
  44. package/internal/workflow/workflow.go +24 -0
  45. package/internal/workflow/workflow_test.go +58 -1
  46. package/package.json +1 -1
package/README.md CHANGED
@@ -16,6 +16,10 @@ Install the latest released CLI with Homebrew:
16
16
  brew install rajpopat27/tap/relay-flow
17
17
  ```
18
18
 
19
+ The package installs both equivalent commands, `relay-flow` and `rf`. The
20
+ short `rf` name is also included in release archives and can be installed by
21
+ `install.sh` or with `go install ./cmd/relay-flow ./cmd/rf` from a checkout.
22
+
19
23
  ### 2. Install the harness extension
20
24
 
21
25
  Choose the harness that will run your agent sessions. Both commands install the
@@ -24,13 +28,13 @@ same `relay-flow-plugin` package with host-specific entrypoints.
24
28
  **OpenCode**
25
29
 
26
30
  ```sh
27
- opencode plugin relay-flow-plugin@0.3.9-alpha
31
+ opencode plugin relay-flow-plugin@0.3.10-alpha
28
32
  ```
29
33
 
30
34
  **Pi**
31
35
 
32
36
  ```sh
33
- pi install npm:relay-flow-plugin@0.3.9-alpha
37
+ pi install npm:relay-flow-plugin@0.3.10-alpha
34
38
  ```
35
39
 
36
40
  Pi loads the package's `pi.ts` extension from its manifest. Do not add
@@ -62,7 +66,7 @@ relay-flow init \
62
66
  --runner-plugin orca \
63
67
  --harness-plugin opencode
64
68
  relay-flow task auth
65
- relay-flow serve --background
69
+ rf serve
66
70
  ```
67
71
 
68
72
  For Beads, skip `task auth` and initialize/authenticate the Beads workspace
@@ -84,7 +88,7 @@ For Herdr, register the repository path directly; relay-flow creates ticket
84
88
  worktrees lazily. The interactive registration asks for the task-system values
85
89
  required by the selected task plugin. `repo register` remains the standalone
86
90
  escape hatch for adding repositories after initialization and requires a
87
- running `relay-flow serve --background`.
91
+ running `rf serve` (or `relay-flow serve`).
88
92
 
89
93
  ### 5. Submit a workflow
90
94
 
@@ -160,7 +164,7 @@ OpenCode plugin configuration uses both entrypoints. The server entrypoint is li
160
164
  ```json
161
165
  {
162
166
  "$schema": "https://opencode.ai/config.json",
163
- "plugin": ["relay-flow-plugin@0.3.9-alpha"]
167
+ "plugin": ["relay-flow-plugin@0.3.10-alpha"]
164
168
  }
165
169
  ```
166
170
 
@@ -169,7 +173,7 @@ The native HITL approval entrypoint is listed in `.opencode/tui.json`:
169
173
  ```json
170
174
  {
171
175
  "$schema": "https://opencode.ai/tui.json",
172
- "plugin": ["relay-flow-plugin@0.3.9-alpha"]
176
+ "plugin": ["relay-flow-plugin@0.3.10-alpha"]
173
177
  }
174
178
  ```
175
179
 
@@ -185,7 +189,7 @@ Pi plugin: install the same published package manually in Pi's global package
185
189
  settings before starting a Pi harness session:
186
190
 
187
191
  ```sh
188
- pi install npm:relay-flow-plugin@0.3.9-alpha
192
+ pi install npm:relay-flow-plugin@0.3.10-alpha
189
193
  ```
190
194
 
191
195
  Relay-flow does not install or configure the package automatically. Pi resolves
@@ -221,7 +225,7 @@ For scripts and existing installations, keep the independent sequence:
221
225
  ```sh
222
226
  relay-flow init --task-plugin jira --runner-plugin orca --harness-plugin opencode
223
227
  relay-flow task auth --site https://company.atlassian.net --email you@example.com --token "$JIRA_API_TOKEN"
224
- relay-flow serve --background
228
+ rf serve
225
229
  ```
226
230
 
227
231
  `task auth` delegates to the selected task plug-in. Jira prompts for its site,
@@ -262,12 +266,11 @@ workflows/<name>.yaml 0644 submitted workflow definitions
262
266
 
263
267
  The guided first-run flow can start the server and open repository registration
264
268
  for you. `repo register` remains a server-backed standalone command for later
265
- additions: if no server is running, start it with
266
- `relay-flow serve --background` (or rerun interactive `relay-flow init` for a
267
- new home).
269
+ additions: if no server is running, start it with `rf serve` (or rerun
270
+ interactive `relay-flow init` for a new home).
268
271
 
269
272
  ```sh
270
- relay-flow serve --background
273
+ rf serve
271
274
  ```
272
275
 
273
276
  The repo must already exist in the runner. For the Orca runner, add it first
@@ -402,13 +405,18 @@ Outside that flow, `repo register` and `workflow submit` require the server to
402
405
  already be running; the same process polls repos and drives runs.
403
406
 
404
407
  ```sh
405
- relay-flow serve # normal start; requires an initialized database
406
- relay-flow serve --background # detached; returns after the server is ready
407
- relay-flow serve --recover # explicit destructive rebuild from the task system
408
- relay-flow stop
409
- ```
410
-
411
- `--background` preserves `--debug` and `--recover`, logs to `~/.relay-flow/server.log`, and remains stoppable with `relay-flow stop`. Plain `serve` remains foreground and blocking.
408
+ rf serve # detached by default; waits for readiness
409
+ relay-flow serve --foreground # blocking mode for supervisors and development
410
+ relay-flow serve --background # compatibility spelling for detached startup
411
+ rf serve --recover # explicit destructive rebuild from the task system
412
+ rf stop
413
+ ```
414
+
415
+ Both executable names share the same home, socket, lock, database, logs, exit
416
+ codes, and server lifecycle. Detached startup logs diagnostics to
417
+ `~/.relay-flow/server.log` and does not inherit a temporary or deleted caller
418
+ working directory. Use `--foreground` only when the process supervisor owns
419
+ the server lifetime; `--background` remains accepted for existing scripts.
412
420
 
413
421
  `serve --recover` treats ALL SQLite execution state as gone, closes surviving run-owned terminals (preserving worktrees and code), resets Jira parent+mailbox state, and starts every labeled parent in a fresh deterministic run from `start` with fresh `nodeVisitID`s. Recovery never runs automatically; database loss is never inferred.
414
422
 
@@ -12,6 +12,9 @@ import (
12
12
  "os"
13
13
  "os/exec"
14
14
  "path/filepath"
15
+ "reflect"
16
+ "runtime"
17
+ "strconv"
15
18
  "strings"
16
19
  "sync"
17
20
  "syscall"
@@ -43,11 +46,11 @@ func cli(t *testing.T, home string, stdin string, args ...string) (code int) {
43
46
  func TestCommandSurfaceExists(t *testing.T) {
44
47
  home := t.TempDir()
45
48
  commands := [][]string{
46
- {"init"}, {"task", "auth"}, {"serve", "--recover"}, {"stop"}, {"report"},
49
+ {"init"}, {"task", "auth"}, {"stop"}, {"report"},
47
50
  {"workflow", "submit", "--file", "x.yaml"}, {"workflow", "remove", "--name", "x"},
48
51
  {"workflow", "list"}, {"workflow", "get", "--name", "x"},
49
52
  {"repo", "register"}, {"repo", "remove", "--name", "x"}, {"repo", "list"}, {"repo", "get", "--name", "x"},
50
- {"run", "list"}, {"run", "get", "--ticket", "PAY-101"}, {"run", "restart", "--ticket", "PAY-101"}, {"run", "cancel", "--ticket", "PAY-101"},
53
+ {"run", "list"}, {"run", "get", "--ticket", "PAY-101"}, {"run", "restart", "--ticket", "PAY-101"}, {"run", "cancel", "--ticket", "PAY-101"}, {"run", "backfill-owner", "--repo", "payments", "--ticket", "PAY-101", "--workflow", "basicFlow"},
51
54
  }
52
55
  for _, argv := range commands {
53
56
  // Recognized commands do not exit 2 ("usage/unknown"); they may exit
@@ -69,14 +72,15 @@ func TestUnknownFlagExits2(t *testing.T) {
69
72
  func TestRequiredFlagMissingExits2(t *testing.T) {
70
73
  home := t.TempDir()
71
74
  for _, argv := range [][]string{
72
- {"workflow", "submit"}, // missing --file
73
- {"workflow", "remove"}, // missing --name
74
- {"workflow", "get"}, // missing --name
75
- {"repo", "remove"}, // missing --name
76
- {"repo", "get"}, // missing --name
77
- {"run", "get"}, // missing --ticket
78
- {"run", "restart"}, // missing --ticket
79
- {"run", "cancel"}, // missing --ticket
75
+ {"workflow", "submit"}, // missing --file
76
+ {"workflow", "remove"}, // missing --name
77
+ {"workflow", "get"}, // missing --name
78
+ {"repo", "remove"}, // missing --name
79
+ {"repo", "get"}, // missing --name
80
+ {"run", "get"}, // missing --ticket
81
+ {"run", "restart"}, // missing --ticket
82
+ {"run", "cancel"}, // missing --ticket
83
+ {"run", "backfill-owner"}, // missing --repo/--ticket/--workflow
80
84
  } {
81
85
  if code := cli(t, home, "", argv...); code != 2 {
82
86
  t.Fatalf("%v with missing required flag exit = %d, want 2", argv, code)
@@ -171,9 +175,19 @@ func TestTaskAuthDispatchesSelectedPlugin(t *testing.T) {
171
175
  func TestServerLockIsOwnerOnly(t *testing.T) {
172
176
  home := t.TempDir()
173
177
  initHome(t, home)
174
- // Start serve in the background of the test via the parser entry; it
175
- // creates the flock file then blocks. We assert the lock file's mode.
176
- go cli(t, home, "", "serve")
178
+ // Start foreground serve in the background of the test via the parser
179
+ // entry; it creates the flock file then blocks. We assert the lock file's
180
+ // mode, then stop the fixture so the test does not leak a server.
181
+ done := make(chan int, 1)
182
+ go func() { done <- cli(t, home, "", "serve", "--foreground") }()
183
+ t.Cleanup(func() {
184
+ _ = cli(t, home, "", "stop")
185
+ select {
186
+ case <-done:
187
+ case <-time.After(5 * time.Second):
188
+ t.Errorf("foreground serve did not stop")
189
+ }
190
+ })
177
191
  lock := filepath.Join(home, ".relay-flow", "server.lock")
178
192
  var fi os.FileInfo
179
193
  var err error
@@ -191,6 +205,7 @@ func TestServerLockIsOwnerOnly(t *testing.T) {
191
205
  if fi.Mode().Perm() != 0600 {
192
206
  t.Fatalf("server.lock mode = %o, want 0600", fi.Mode().Perm())
193
207
  }
208
+ waitForServer(t, server.NewClient(filepath.Join(home, ".relay-flow", "server.sock")))
194
209
  }
195
210
 
196
211
  // 3.29 (socket): the serve startup path owns server.sock creation and chmods
@@ -199,7 +214,16 @@ func TestServerLockIsOwnerOnly(t *testing.T) {
199
214
  func TestServerSocketIsOwnerOnly(t *testing.T) {
200
215
  home := t.TempDir()
201
216
  initHome(t, home)
202
- go cli(t, home, "", "serve")
217
+ done := make(chan int, 1)
218
+ go func() { done <- cli(t, home, "", "serve", "--foreground") }()
219
+ t.Cleanup(func() {
220
+ _ = cli(t, home, "", "stop")
221
+ select {
222
+ case <-done:
223
+ case <-time.After(5 * time.Second):
224
+ t.Errorf("foreground serve did not stop")
225
+ }
226
+ })
203
227
  sock := filepath.Join(home, ".relay-flow", "server.sock")
204
228
  var fi os.FileInfo
205
229
  var err error
@@ -1009,7 +1033,239 @@ func TestRepoRegistrationPartialFailureKeepsPriorSuccess(t *testing.T) {
1009
1033
  }
1010
1034
  }
1011
1035
 
1036
+ func TestDefaultServeReadinessAndStop(t *testing.T) {
1037
+ testDetachedServe(t, nil)
1038
+ }
1039
+
1040
+ func TestDefaultServeRelativeHomeUsesSharedState(t *testing.T) {
1041
+ caller := t.TempDir()
1042
+ relativeHome := ".rf-rel"
1043
+ expectedRoot := filepath.Join(caller, relativeHome)
1044
+ t.Setenv("RELAY_FLOW_HOME", relativeHome)
1045
+ binary := buildCLIBinary(t)
1046
+ t.Cleanup(func() {
1047
+ stop := exec.Command(binary, "stop")
1048
+ stop.Dir = caller
1049
+ stop.Env = os.Environ()
1050
+ _, _ = stop.CombinedOutput()
1051
+ })
1052
+
1053
+ init := exec.Command(binary, initArgs()...)
1054
+ init.Dir = caller
1055
+ init.Env = os.Environ()
1056
+ if out, err := init.CombinedOutput(); err != nil {
1057
+ t.Fatalf("relative-home init: %v\n%s", err, out)
1058
+ }
1059
+ for _, name := range []string{"config.yaml", "state.db"} {
1060
+ if _, err := os.Stat(filepath.Join(expectedRoot, name)); err != nil {
1061
+ t.Fatalf("relative-home init missing %s: %v", name, err)
1062
+ }
1063
+ }
1064
+
1065
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1066
+ defer cancel()
1067
+ serve := exec.CommandContext(ctx, binary, "serve")
1068
+ serve.Dir = caller
1069
+ serve.Env = os.Environ()
1070
+ out, err := serve.CombinedOutput()
1071
+ if err != nil {
1072
+ t.Fatalf("relative-home detached serve: %v\n%s", err, out)
1073
+ }
1074
+ client := server.NewClient(filepath.Join(expectedRoot, "server.sock"))
1075
+ if _, err := client.ListRepos(context.Background()); err != nil {
1076
+ t.Fatalf("relative-home server did not respond: %v", err)
1077
+ }
1078
+ stop := exec.Command(binary, "stop")
1079
+ stop.Dir = caller
1080
+ stop.Env = os.Environ()
1081
+ if out, err := stop.CombinedOutput(); err != nil {
1082
+ t.Fatalf("relative-home stop: %v\n%s", err, out)
1083
+ }
1084
+ waitForServerStop(t, client)
1085
+ }
1086
+
1087
+ func TestInProcessDefaultServeReadinessAndStop(t *testing.T) {
1088
+ home := t.TempDir()
1089
+ initHome(t, home)
1090
+ if code := cli(t, home, "", "serve"); code != exitOK {
1091
+ t.Fatalf("in-process detached serve exit = %d", code)
1092
+ }
1093
+ client := server.NewClient(filepath.Join(home, ".relay-flow", "server.sock"))
1094
+ if _, err := client.ListRepos(context.Background()); err != nil {
1095
+ t.Fatalf("in-process detached server did not respond: %v", err)
1096
+ }
1097
+ if code := cli(t, home, "", "stop"); code != exitOK {
1098
+ t.Fatalf("in-process detached stop exit = %d", code)
1099
+ }
1100
+ waitForServerStop(t, client)
1101
+ }
1102
+
1103
+ func TestDetachedServeUsesStableWorkingDirectory(t *testing.T) {
1104
+ home := t.TempDir()
1105
+ root := filepath.Join(home, ".relay-flow")
1106
+ t.Setenv("RELAY_FLOW_HOME", root)
1107
+ if code := run(initArgs(), strings.NewReader("")); code != 0 {
1108
+ t.Fatalf("init exit = %d", code)
1109
+ }
1110
+ binary := buildCLIBinary(t)
1111
+ t.Cleanup(func() {
1112
+ stop := exec.Command(binary, "stop")
1113
+ stop.Env = os.Environ()
1114
+ _, _ = stop.CombinedOutput()
1115
+ })
1116
+ caller := t.TempDir()
1117
+ serve := exec.Command(binary, "serve")
1118
+ serve.Dir = caller
1119
+ serve.Env = os.Environ()
1120
+ out, err := serve.CombinedOutput()
1121
+ if err != nil {
1122
+ t.Fatalf("detached serve from temporary directory: %v\n%s", err, out)
1123
+ }
1124
+ client := server.NewClient(filepath.Join(root, "server.sock"))
1125
+ if _, err := client.ListRepos(context.Background()); err != nil {
1126
+ t.Fatalf("ready server did not respond: %v", err)
1127
+ }
1128
+ if err := os.RemoveAll(caller); err != nil {
1129
+ t.Fatal(err)
1130
+ }
1131
+ cwd, ok, inspectErr := waitForServeProcessCWD(binary)
1132
+ if inspectErr != nil {
1133
+ t.Skipf("process cwd inspection unavailable: %v", inspectErr)
1134
+ }
1135
+ if !ok {
1136
+ t.Fatalf("could not find detached serve process")
1137
+ }
1138
+ if cwd != root {
1139
+ t.Fatalf("detached server cwd = %q, want stable root %q", cwd, root)
1140
+ }
1141
+ stop := exec.Command(binary, "stop")
1142
+ stop.Env = os.Environ()
1143
+ if out, err := stop.CombinedOutput(); err != nil {
1144
+ t.Fatalf("stop: %v\n%s", err, out)
1145
+ }
1146
+ waitForServerStop(t, client)
1147
+ }
1148
+
1149
+ func TestForegroundServeUsesStableWorkingDirectory(t *testing.T) {
1150
+ home := t.TempDir()
1151
+ root := filepath.Join(home, ".relay-flow")
1152
+ t.Setenv("RELAY_FLOW_HOME", root)
1153
+ if code := run(initArgs(), strings.NewReader("")); code != 0 {
1154
+ t.Fatalf("init exit = %d", code)
1155
+ }
1156
+ binary := buildCLIBinary(t)
1157
+ caller := t.TempDir()
1158
+ serve := exec.Command(binary, "serve", "--foreground")
1159
+ serve.Dir = caller
1160
+ serve.Env = os.Environ()
1161
+ if err := serve.Start(); err != nil {
1162
+ t.Fatal(err)
1163
+ }
1164
+ wait := make(chan error, 1)
1165
+ go func() { wait <- serve.Wait() }()
1166
+ t.Cleanup(func() {
1167
+ if serve.Process != nil {
1168
+ _ = serve.Process.Kill()
1169
+ }
1170
+ })
1171
+ client := server.NewClient(filepath.Join(root, "server.sock"))
1172
+ waitForServer(t, client)
1173
+ if err := os.RemoveAll(caller); err != nil {
1174
+ t.Fatal(err)
1175
+ }
1176
+ cwd, ok, inspectErr := waitForServeProcessCWD(binary)
1177
+ if inspectErr != nil {
1178
+ t.Skipf("process cwd inspection unavailable: %v", inspectErr)
1179
+ }
1180
+ if !ok {
1181
+ t.Fatalf("could not find foreground serve process")
1182
+ }
1183
+ if cwd != root {
1184
+ t.Fatalf("foreground server cwd = %q, want stable root %q", cwd, root)
1185
+ }
1186
+ stop := exec.Command(binary, "stop")
1187
+ stop.Env = os.Environ()
1188
+ if out, err := stop.CombinedOutput(); err != nil {
1189
+ t.Fatalf("stop: %v\n%s", err, out)
1190
+ }
1191
+ select {
1192
+ case err := <-wait:
1193
+ if err != nil {
1194
+ t.Fatalf("foreground serve exit: %v", err)
1195
+ }
1196
+ case <-time.After(5 * time.Second):
1197
+ t.Fatal("foreground serve did not exit after stop")
1198
+ }
1199
+ }
1200
+
1201
+ func TestForegroundServeRelativeHomeUsesSharedState(t *testing.T) {
1202
+ caller := t.TempDir()
1203
+ relativeHome := ".rf-fg"
1204
+ expectedRoot := filepath.Join(caller, relativeHome)
1205
+ t.Setenv("RELAY_FLOW_HOME", relativeHome)
1206
+ binary := buildCLIBinary(t)
1207
+
1208
+ init := exec.Command(binary, initArgs()...)
1209
+ init.Dir = caller
1210
+ init.Env = os.Environ()
1211
+ if out, err := init.CombinedOutput(); err != nil {
1212
+ t.Fatalf("relative-home init: %v\n%s", err, out)
1213
+ }
1214
+ serve := exec.Command(binary, "serve", "--foreground")
1215
+ serve.Dir = caller
1216
+ serve.Env = os.Environ()
1217
+ var serveStdout, serveStderr bytes.Buffer
1218
+ serve.Stdout = &serveStdout
1219
+ serve.Stderr = &serveStderr
1220
+ if err := serve.Start(); err != nil {
1221
+ t.Fatal(err)
1222
+ }
1223
+ wait := make(chan error, 1)
1224
+ go func() { wait <- serve.Wait() }()
1225
+ t.Cleanup(func() {
1226
+ if serve.Process != nil {
1227
+ _ = serve.Process.Kill()
1228
+ }
1229
+ })
1230
+ client := server.NewClient(filepath.Join(expectedRoot, "server.sock"))
1231
+ if err := waitForServerResult(client, 15*time.Second); err != nil {
1232
+ logData, _ := os.ReadFile(filepath.Join(expectedRoot, "server.log"))
1233
+ t.Fatalf("relative-home foreground readiness: %v; stdout=%q stderr=%q server.log=%q", err, serveStdout.String(), serveStderr.String(), string(logData))
1234
+ }
1235
+ for _, name := range []string{"config.yaml", "state.db", "server.sock", "server.lock"} {
1236
+ if _, err := os.Stat(filepath.Join(expectedRoot, name)); err != nil {
1237
+ t.Fatalf("relative-home foreground missing %s: %v", name, err)
1238
+ }
1239
+ }
1240
+ cwd, ok, inspectErr := waitForServeProcessCWD(binary)
1241
+ if inspectErr != nil {
1242
+ t.Skipf("process cwd inspection unavailable: %v", inspectErr)
1243
+ }
1244
+ if !ok || cwd != expectedRoot {
1245
+ t.Fatalf("relative-home foreground cwd = %q (found=%v), want %q", cwd, ok, expectedRoot)
1246
+ }
1247
+ stop := exec.Command(binary, "stop")
1248
+ stop.Dir = caller
1249
+ stop.Env = os.Environ()
1250
+ if out, err := stop.CombinedOutput(); err != nil {
1251
+ t.Fatalf("relative-home foreground stop: %v\n%s", err, out)
1252
+ }
1253
+ select {
1254
+ case err := <-wait:
1255
+ if err != nil {
1256
+ t.Fatalf("relative-home foreground serve exit: %v", err)
1257
+ }
1258
+ case <-time.After(5 * time.Second):
1259
+ t.Fatal("relative-home foreground serve did not exit after stop")
1260
+ }
1261
+ }
1262
+
1012
1263
  func TestBackgroundServeReadinessAndStop(t *testing.T) {
1264
+ testDetachedServe(t, []string{"--background"})
1265
+ }
1266
+
1267
+ func testDetachedServe(t *testing.T, mode []string) {
1268
+ t.Helper()
1013
1269
  home := t.TempDir()
1014
1270
  root := filepath.Join(home, ".relay-flow")
1015
1271
  t.Setenv("RELAY_FLOW_HOME", root)
@@ -1017,14 +1273,15 @@ func TestBackgroundServeReadinessAndStop(t *testing.T) {
1017
1273
  t.Fatalf("init exit = %d", code)
1018
1274
  }
1019
1275
  binary := buildCLIBinary(t)
1020
- start := exec.Command(binary, "serve", "--background")
1276
+ args := append([]string{"serve"}, mode...)
1277
+ start := exec.Command(binary, args...)
1021
1278
  start.Env = os.Environ()
1022
1279
  out, err := start.CombinedOutput()
1023
1280
  if err != nil {
1024
- t.Fatalf("background serve: %v\n%s", err, out)
1281
+ t.Fatalf("detached serve: %v\n%s", err, out)
1025
1282
  }
1026
1283
  if !strings.Contains(string(out), "Relay-flow server started") {
1027
- t.Fatalf("background output = %q", out)
1284
+ t.Fatalf("detached output = %q", out)
1028
1285
  }
1029
1286
  client := server.NewClient(filepath.Join(root, "server.sock"))
1030
1287
  if _, err := client.ListRepos(context.Background()); err != nil {
@@ -1038,6 +1295,29 @@ func TestBackgroundServeReadinessAndStop(t *testing.T) {
1038
1295
  waitForServerStop(t, client)
1039
1296
  }
1040
1297
 
1298
+ func TestServeRejectsConflictingModes(t *testing.T) {
1299
+ if code := cli(t, t.TempDir(), "", "serve", "--foreground", "--background"); code != exitUsage {
1300
+ t.Fatalf("conflicting serve modes exit = %d, want %d", code, exitUsage)
1301
+ }
1302
+ }
1303
+
1304
+ func TestServeChildArgsPropagateOnlyRecoveryAndDebug(t *testing.T) {
1305
+ for _, tc := range []struct {
1306
+ recover bool
1307
+ debug bool
1308
+ want []string
1309
+ }{
1310
+ {want: []string{"serve"}},
1311
+ {recover: true, want: []string{"serve", "--recover"}},
1312
+ {debug: true, want: []string{"serve", "--debug"}},
1313
+ {recover: true, debug: true, want: []string{"serve", "--recover", "--debug"}},
1314
+ } {
1315
+ if got := serveChildArgs(tc.recover, tc.debug); !reflect.DeepEqual(got, tc.want) {
1316
+ t.Fatalf("serveChildArgs(%v, %v) = %v, want %v", tc.recover, tc.debug, got, tc.want)
1317
+ }
1318
+ }
1319
+ }
1320
+
1041
1321
  func TestBackgroundServeFailurePointsToLog(t *testing.T) {
1042
1322
  home := t.TempDir()
1043
1323
  root := filepath.Join(home, ".relay-flow")
@@ -1064,6 +1344,33 @@ func TestBackgroundServeFailurePointsToLog(t *testing.T) {
1064
1344
  }
1065
1345
  }
1066
1346
 
1347
+ func TestAnchorForegroundServeNormalizesHomeEnvironment(t *testing.T) {
1348
+ originalWD, err := os.Getwd()
1349
+ if err != nil {
1350
+ t.Fatal(err)
1351
+ }
1352
+ originalPWD, hadPWD := os.LookupEnv("PWD")
1353
+ defer func() {
1354
+ _ = os.Chdir(originalWD)
1355
+ if hadPWD {
1356
+ _ = os.Setenv("PWD", originalPWD)
1357
+ } else {
1358
+ _ = os.Unsetenv("PWD")
1359
+ }
1360
+ }()
1361
+ root := filepath.Join(t.TempDir(), "stable-home")
1362
+ t.Setenv("RELAY_FLOW_HOME", "relative-home")
1363
+ if err := anchorForegroundServe(root); err != nil {
1364
+ t.Fatal(err)
1365
+ }
1366
+ if got := os.Getenv("RELAY_FLOW_HOME"); got != root {
1367
+ t.Fatalf("RELAY_FLOW_HOME = %q, want %q", got, root)
1368
+ }
1369
+ if got, err := os.Getwd(); err != nil || got != root {
1370
+ t.Fatalf("working directory = %q, %v; want %q", got, err, root)
1371
+ }
1372
+ }
1373
+
1067
1374
  func TestForegroundServeRemainsBlocking(t *testing.T) {
1068
1375
  home := t.TempDir()
1069
1376
  root := filepath.Join(home, ".relay-flow")
@@ -1072,7 +1379,7 @@ func TestForegroundServeRemainsBlocking(t *testing.T) {
1072
1379
  t.Fatalf("init exit = %d", code)
1073
1380
  }
1074
1381
  binary := buildCLIBinary(t)
1075
- serve := exec.Command(binary, "serve")
1382
+ serve := exec.Command(binary, "serve", "--foreground")
1076
1383
  serve.Env = os.Environ()
1077
1384
  if err := serve.Start(); err != nil {
1078
1385
  t.Fatal(err)
@@ -1106,6 +1413,104 @@ func TestForegroundServeRemainsBlocking(t *testing.T) {
1106
1413
  }
1107
1414
  }
1108
1415
 
1416
+ func waitForServeProcessCWD(executable string) (string, bool, error) {
1417
+ deadline := time.Now().Add(5 * time.Second)
1418
+ for time.Now().Before(deadline) {
1419
+ pids, err := serveProcessIDs(executable)
1420
+ if err != nil {
1421
+ return "", false, err
1422
+ }
1423
+ for _, pid := range pids {
1424
+ cwd, readErr := processCWD(pid)
1425
+ if readErr == nil {
1426
+ return cwd, true, nil
1427
+ }
1428
+ if runtime.GOOS == "darwin" {
1429
+ return "", false, readErr
1430
+ }
1431
+ }
1432
+ time.Sleep(20 * time.Millisecond)
1433
+ }
1434
+ return "", false, nil
1435
+ }
1436
+
1437
+ func serveProcessIDs(executable string) ([]int, error) {
1438
+ switch runtime.GOOS {
1439
+ case "linux":
1440
+ entries, err := os.ReadDir("/proc")
1441
+ if err != nil {
1442
+ return nil, fmt.Errorf("read /proc: %w", err)
1443
+ }
1444
+ var pids []int
1445
+ for _, entry := range entries {
1446
+ if !entry.IsDir() || entry.Name() == "self" {
1447
+ continue
1448
+ }
1449
+ cmdline, readErr := os.ReadFile(filepath.Join("/proc", entry.Name(), "cmdline"))
1450
+ if readErr != nil {
1451
+ continue
1452
+ }
1453
+ args := strings.Split(strings.TrimRight(string(cmdline), "\x00"), "\x00")
1454
+ if len(args) < 2 || args[0] != executable || args[1] != "serve" {
1455
+ continue
1456
+ }
1457
+ pid, parseErr := strconv.Atoi(entry.Name())
1458
+ if parseErr == nil {
1459
+ pids = append(pids, pid)
1460
+ }
1461
+ }
1462
+ return pids, nil
1463
+ case "darwin":
1464
+ output, err := exec.Command("ps", "-axo", "pid=,command=").Output()
1465
+ if err != nil {
1466
+ return nil, fmt.Errorf("list processes with ps: %w", err)
1467
+ }
1468
+ var pids []int
1469
+ for _, line := range strings.Split(string(output), "\n") {
1470
+ line = strings.TrimSpace(line)
1471
+ fields := strings.Fields(line)
1472
+ if len(fields) < 2 {
1473
+ continue
1474
+ }
1475
+ pid, parseErr := strconv.Atoi(fields[0])
1476
+ if parseErr != nil {
1477
+ continue
1478
+ }
1479
+ command := strings.TrimSpace(strings.TrimPrefix(line, fields[0]))
1480
+ if strings.HasPrefix(command, executable+" serve") {
1481
+ pids = append(pids, pid)
1482
+ }
1483
+ }
1484
+ return pids, nil
1485
+ default:
1486
+ return nil, fmt.Errorf("process cwd inspection is unsupported on %s", runtime.GOOS)
1487
+ }
1488
+ }
1489
+
1490
+ func processCWD(pid int) (string, error) {
1491
+ switch runtime.GOOS {
1492
+ case "linux":
1493
+ return os.Readlink(filepath.Join("/proc", strconv.Itoa(pid), "cwd"))
1494
+ case "darwin":
1495
+ lsof, err := exec.LookPath("lsof")
1496
+ if err != nil {
1497
+ return "", fmt.Errorf("find lsof for Darwin cwd inspection: %w", err)
1498
+ }
1499
+ output, err := exec.Command(lsof, "-a", "-p", strconv.Itoa(pid), "-d", "cwd", "-Fn").Output()
1500
+ if err != nil {
1501
+ return "", fmt.Errorf("inspect cwd with lsof: %w", err)
1502
+ }
1503
+ for _, line := range strings.Split(string(output), "\n") {
1504
+ if strings.HasPrefix(line, "n") {
1505
+ return strings.TrimPrefix(line, "n"), nil
1506
+ }
1507
+ }
1508
+ return "", fmt.Errorf("lsof returned no cwd for pid %d", pid)
1509
+ default:
1510
+ return "", fmt.Errorf("process cwd inspection is unsupported on %s", runtime.GOOS)
1511
+ }
1512
+ }
1513
+
1109
1514
  func buildCLIBinary(t *testing.T) string {
1110
1515
  t.Helper()
1111
1516
  binary := filepath.Join(t.TempDir(), "relay-flow")
@@ -1119,17 +1524,28 @@ func buildCLIBinary(t *testing.T) string {
1119
1524
 
1120
1525
  func waitForServer(t *testing.T, client *server.Client) {
1121
1526
  t.Helper()
1122
- deadline := time.Now().Add(5 * time.Second)
1527
+ waitForServerTimeout(t, client, 5*time.Second)
1528
+ }
1529
+
1530
+ func waitForServerTimeout(t *testing.T, client *server.Client, timeout time.Duration) {
1531
+ t.Helper()
1532
+ if err := waitForServerResult(client, timeout); err != nil {
1533
+ t.Fatal(err)
1534
+ }
1535
+ }
1536
+
1537
+ func waitForServerResult(client *server.Client, timeout time.Duration) error {
1538
+ deadline := time.Now().Add(timeout)
1123
1539
  for time.Now().Before(deadline) {
1124
1540
  ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
1125
1541
  _, err := client.ListRepos(ctx)
1126
1542
  cancel()
1127
1543
  if err == nil {
1128
- return
1544
+ return nil
1129
1545
  }
1130
1546
  time.Sleep(20 * time.Millisecond)
1131
1547
  }
1132
- t.Fatal("server did not become ready")
1548
+ return fmt.Errorf("server did not become ready")
1133
1549
  }
1134
1550
 
1135
1551
  func waitForServerStop(t *testing.T, client *server.Client) {
@@ -1187,6 +1603,9 @@ func (s *ackServer) RestartRun(context.Context, string) (runsvc.Run, error) {
1187
1603
  panic("unreachable")
1188
1604
  }
1189
1605
  func (s *ackServer) CancelRun(context.Context, string, string) error { panic("unreachable") }
1606
+ func (s *ackServer) BackfillClaimOwner(context.Context, string, string, string) error {
1607
+ panic("unreachable")
1608
+ }
1190
1609
  func (s *ackServer) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) {
1191
1610
  panic("unreachable")
1192
1611
  }