relay-flow 0.2.4-alpha → 0.2.5-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 (45) hide show
  1. package/README.md +20 -15
  2. package/cmd/relay-flow/backend_selection_test.go +149 -0
  3. package/cmd/relay-flow/main.go +94 -13
  4. package/cmd/relay-flow/scenario_test.go +19 -2
  5. package/cmd/relay-flow/serve.go +98 -19
  6. package/cmd/relay-flow/serve_recovery_test.go +100 -0
  7. package/cmd/relay-flow/temporal_init.go +170 -0
  8. package/cmd/relay-flow/temporal_init_test.go +217 -0
  9. package/cmd/relay-flow/temporal_report_test.go +733 -0
  10. package/examples/config-reference.yaml +2 -2
  11. package/examples/minimal-beads-task-workflow.yaml +2 -1
  12. package/examples/workflow-reference.yaml +2 -1
  13. package/go.mod +37 -16
  14. package/go.sum +129 -61
  15. package/internal/config/machine.go +33 -1
  16. package/internal/config/machine_test.go +76 -0
  17. package/internal/execution/goworkflows/engine.go +13 -38
  18. package/internal/execution/goworkflows/projection.go +47 -464
  19. package/internal/execution/projection/projection.go +867 -0
  20. package/internal/execution/projection/projection_test.go +347 -0
  21. package/internal/execution/temporal/activities.go +567 -0
  22. package/internal/execution/temporal/engine.go +384 -0
  23. package/internal/execution/temporal/engine_test.go +277 -0
  24. package/internal/execution/temporal/interpreter.go +736 -0
  25. package/internal/execution/temporal/operations.go +455 -0
  26. package/internal/execution/temporal/operations_test.go +101 -0
  27. package/internal/execution/temporal/recovery.go +194 -0
  28. package/internal/execution/temporal/recovery_runtime.go +41 -0
  29. package/internal/execution/temporal/recovery_test.go +102 -0
  30. package/internal/execution/temporal/snapshot_restart_test.go +72 -0
  31. package/internal/execution/temporal/spike_test.go +934 -0
  32. package/internal/execution/temporal/visibility_lag_test.go +415 -0
  33. package/internal/harness/opencode/opencode.go +3 -1
  34. package/internal/harness/opencode/opencode_test.go +1 -1
  35. package/internal/harness/opencode/repo_setup.go +1 -1
  36. package/internal/harness/pi/pi.go +49 -46
  37. package/internal/harness/pi/pi_test.go +26 -10
  38. package/internal/harness/pi/prompt_test.go +30 -1
  39. package/internal/harness/pi/validation_test.go +27 -51
  40. package/internal/runner/herdr/herdr.go +14 -0
  41. package/internal/runner/herdr/herdr_test.go +20 -0
  42. package/internal/runner/orca/orca.go +33 -0
  43. package/internal/runner/orca/orca_test.go +33 -4
  44. package/internal/runner/runner.go +8 -0
  45. package/package.json +1 -1
@@ -20,7 +20,7 @@ import (
20
20
  // shape. The fake is intentionally an executable on PATH rather than a
21
21
  // production lookup seam: Pi availability is covered separately, while this
22
22
  // test verifies the opaque runner command and its handoff to the ticket
23
- // environment, including an optional repository role prompt.
23
+ // environment, including an optional native prompt template.
24
24
  func TestBuildCommandUsesStrictPiCLIContract(t *testing.T) {
25
25
  fakeDir, capturePath := strictPiCLI(t)
26
26
  t.Setenv("PATH", fakeDir+string(os.PathListSeparator)+os.Getenv("PATH"))
@@ -123,19 +123,18 @@ func TestBuildCommandKeepsDashPrefixedPromptPositional(t *testing.T) {
123
123
  }
124
124
  }
125
125
 
126
- func TestBuildCommandAddsExistingRolePrompt(t *testing.T) {
126
+ func TestBuildCommandUsesNativePromptTemplate(t *testing.T) {
127
127
  fakeDir, capturePath := strictPiCLI(t)
128
128
  t.Setenv("PATH", fakeDir+string(os.PathListSeparator)+os.Getenv("PATH"))
129
129
  t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
130
130
 
131
131
  spec := launchSpec(t)
132
132
  spec.Agent = "coder"
133
- rolePath := writePiRole(t, spec.RepoPath, spec.Agent, "You are the coder for relay-flow.\n")
134
133
  cmd, err := newPiHarness(t).BuildCommand(spec)
135
134
  if err != nil {
136
135
  t.Fatalf("BuildCommand: %v", err)
137
136
  }
138
- want := []string{"--name", spec.Title, "--append-system-prompt", rolePath, spec.Prompt}
137
+ want := []string{"--name", spec.Title, "--prompt-template", ".pi/prompts/coder.md", "/coder " + spec.Prompt}
139
138
  if !reflect.DeepEqual(cmd.Args, want) {
140
139
  t.Fatalf("Args = %#v, want %#v", cmd.Args, want)
141
140
  }
@@ -143,14 +142,28 @@ func TestBuildCommandAddsExistingRolePrompt(t *testing.T) {
143
142
  if !reflect.DeepEqual(capture.args, want) {
144
143
  t.Fatalf("fake Pi args = %#v, want %#v", capture.args, want)
145
144
  }
145
+
146
+ spec.ResumeID = "session-123"
147
+ cmd, err = newPiHarness(t).BuildCommand(spec)
148
+ if err != nil {
149
+ t.Fatalf("BuildCommand(resume): %v", err)
150
+ }
151
+ want = []string{"--name", spec.Title, "--prompt-template", ".pi/prompts/coder.md", "--session-id", spec.ResumeID, "/coder " + spec.Prompt}
152
+ if !reflect.DeepEqual(cmd.Args, want) {
153
+ t.Fatalf("resume Args = %#v, want %#v", cmd.Args, want)
154
+ }
146
155
  }
147
156
 
148
- func TestBuildCommandRejectsMissingRolePrompt(t *testing.T) {
157
+ func TestBuildCommandDoesNotValidatePromptTemplateFile(t *testing.T) {
149
158
  spec := launchSpec(t)
150
159
  spec.Agent = "reviewer"
151
- _, err := newPiHarness(t).BuildCommand(spec)
152
- if err == nil || !strings.Contains(err.Error(), ".pi/roles/reviewer.md") {
153
- t.Fatalf("missing role error = %v, want role path", err)
160
+ cmd, err := newPiHarness(t).BuildCommand(spec)
161
+ if err != nil {
162
+ t.Fatalf("BuildCommand: %v", err)
163
+ }
164
+ want := []string{"--name", spec.Title, "--prompt-template", ".pi/prompts/reviewer.md", "/reviewer " + spec.Prompt}
165
+ if !reflect.DeepEqual(cmd.Args, want) {
166
+ t.Fatalf("Args = %#v, want %#v", cmd.Args, want)
154
167
  }
155
168
  }
156
169
 
@@ -290,9 +303,12 @@ done
290
303
  [ "${1:-}" = "--name" ] || exit 2
291
304
  [ "$#" -ge 3 ] || exit 2
292
305
  shift 2
293
- if [ "${1:-}" = "--append-system-prompt" ]; then
306
+ if [ "${1:-}" = "--prompt-template" ]; then
294
307
  [ "$#" -ge 3 ] || exit 2
295
- [ -s "$2" ] || exit 2
308
+ case "$2" in
309
+ .pi/prompts/*.md) ;;
310
+ *) exit 2 ;;
311
+ esac
296
312
  shift 2
297
313
  fi
298
314
  if [ "${1:-}" = "--session-id" ]; then
@@ -29,7 +29,7 @@ func TestPiRenderPromptSubstitutesInitialAndFeedbackData(t *testing.T) {
29
29
  if err != nil {
30
30
  t.Fatalf("RenderPrompt(initial): %v", err)
31
31
  }
32
- wantInitial := "Task system: jira\nUse the jira tools to read the parent ticket PAY-101.\n\nYour mailbox is PAY-234. Read its description and comments for node instructions and feedback.\n\nnudge jira|PAY-101|basicFlow|payments|implement|review (when: ready)"
32
+ wantInitial := "Task system: jira\nUse the jira tools to read the parent ticket PAY-101.\n\nYour mailbox is PAY-234. Read its description and comments for node instructions and feedback.\n\nKeep the summary brief, and make the feedback as detailed and actionable as possible for the next agent.\n\nnudge jira|PAY-101|basicFlow|payments|implement|review (when: ready)"
33
33
  if initial != wantInitial {
34
34
  t.Fatalf("initial prompt = %q, want %q", initial, wantInitial)
35
35
  }
@@ -97,6 +97,35 @@ func TestPiPromptRetainsDefaultAgentLabel(t *testing.T) {
97
97
  }
98
98
  }
99
99
 
100
+ func TestPiRenderPromptUsesNativeTemplateCommandForNamedAgent(t *testing.T) {
101
+ h := newPiHarness(t)
102
+ data := harness.PromptData{
103
+ TaskSystem: "jira",
104
+ Ticket: "PAY-101",
105
+ Node: "implement",
106
+ Agent: "coder",
107
+ Mailbox: "PAY-234",
108
+ }
109
+
110
+ initial, err := h.RenderPrompt(harness.PromptInitial, data, "nudge {{node}}")
111
+ if err != nil {
112
+ t.Fatalf("RenderPrompt(initial): %v", err)
113
+ }
114
+ wantInitial := "/coder Task system: jira\nUse the jira tools to read the parent ticket PAY-101.\n\nYour mailbox is PAY-234. Read its description and comments for node instructions and feedback.\n\nKeep the summary brief, and make the feedback as detailed and actionable as possible for the next agent.\n\nnudge implement"
115
+ if initial != wantInitial {
116
+ t.Fatalf("initial prompt = %q, want %q", initial, wantInitial)
117
+ }
118
+
119
+ feedback, err := h.RenderPrompt(harness.PromptFeedback, data, "feedback nudge")
120
+ if err != nil {
121
+ t.Fatalf("RenderPrompt(feedback): %v", err)
122
+ }
123
+ wantFeedback := "/coder New feedback was added to the comments section of your mailbox subtask PAY-234. Read it.\n\nfeedback nudge"
124
+ if feedback != wantFeedback {
125
+ t.Fatalf("feedback prompt = %q, want %q", feedback, wantFeedback)
126
+ }
127
+ }
128
+
100
129
  func TestPiPromptsDoNotIncludeOpenCodeQuestionInstructions(t *testing.T) {
101
130
  h := newPiHarness(t)
102
131
  data := harness.PromptData{
@@ -33,93 +33,81 @@ func TestPiValidateAgentRejectsUnavailablePi(t *testing.T) {
33
33
  }
34
34
  }
35
35
 
36
- func TestPiValidateAgentAcceptsExistingRoleAndPiOnPath(t *testing.T) {
36
+ func TestPiValidateAgentAcceptsNamedPromptTemplateAndPiOnPath(t *testing.T) {
37
37
  fakeDir := strictPiAvailabilityCLI(t)
38
38
  t.Setenv("PATH", fakeDir)
39
39
  repoPath := t.TempDir()
40
- rolePath := writePiRole(t, repoPath, "coder", "You are the coder.\n")
41
40
 
42
41
  h := newPiHarness(t)
43
42
  if err := h.ValidateAgent(context.Background(), repoPath, "coder"); err != nil {
44
- t.Fatalf("existing role %q rejected: %v", rolePath, err)
43
+ t.Fatalf("prompt template rejected: %v", err)
45
44
  }
46
45
  if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
47
- t.Fatalf("role validation unexpectedly executed Pi: %v", calls)
46
+ // ValidateAgent only performs executable lookup; it must not execute Pi.
47
+ t.Fatalf("unexpected Pi execution during prompt-template validation: %v", calls)
48
48
  }
49
49
  }
50
50
 
51
- func TestPiValidateAgentRejectsMissingRoleBeforePiLookup(t *testing.T) {
51
+ func TestPiValidateAgentDoesNotInspectPromptTemplateBeforePiLookup(t *testing.T) {
52
52
  fakeDir := strictPiAvailabilityCLI(t)
53
53
  t.Setenv("PATH", fakeDir)
54
54
 
55
55
  h := newPiHarness(t)
56
56
  err := h.ValidateAgent(context.Background(), t.TempDir(), "reviewer")
57
- if err == nil || !strings.Contains(err.Error(), ".pi/roles/reviewer.md") {
58
- t.Fatalf("missing role error = %v, want role path", err)
57
+ if err != nil {
58
+ t.Fatalf("missing prompt template rejected: %v", err)
59
59
  }
60
60
  if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
61
- t.Fatalf("missing role invoked Pi lookup: %v", calls)
61
+ t.Fatalf("prompt-template validation executed Pi: %v", calls)
62
62
  }
63
63
  }
64
64
 
65
- func TestPiValidateAgentRejectsUnsafeRolePath(t *testing.T) {
65
+ func TestPiValidateAgentRejectsUnsafePromptTemplateName(t *testing.T) {
66
66
  fakeDir := strictPiAvailabilityCLI(t)
67
67
  t.Setenv("PATH", fakeDir)
68
68
 
69
69
  h := newPiHarness(t)
70
- for _, role := range []string{"../coder", "subdir/coder", `subdir\\coder`, " ", "."} {
71
- if err := h.ValidateAgent(context.Background(), t.TempDir(), role); err == nil {
72
- t.Errorf("unsafe role %q accepted", role)
70
+ for _, name := range []string{"../coder", "subdir/coder", `subdir\\coder`, " ", "."} {
71
+ if err := h.ValidateAgent(context.Background(), t.TempDir(), name); err == nil {
72
+ t.Errorf("unsafe prompt template name %q accepted", name)
73
73
  }
74
74
  }
75
75
  if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
76
- t.Fatalf("unsafe roles invoked Pi lookup: %v", calls)
76
+ t.Fatalf("unsafe prompt template names invoked Pi lookup: %v", calls)
77
77
  }
78
78
  }
79
79
 
80
- func TestPiValidateAgentRejectsEmptyAndNonRegularRoleFiles(t *testing.T) {
80
+ func TestPiValidateAgentAcceptsMissingAndNonRegularPromptTemplateFiles(t *testing.T) {
81
81
  fakeDir := strictPiAvailabilityCLI(t)
82
82
  t.Setenv("PATH", fakeDir)
83
83
 
84
- emptyRepo := t.TempDir()
85
- emptyPath := filepath.Join(emptyRepo, ".pi", "roles", "coder.md")
86
- if err := os.MkdirAll(filepath.Dir(emptyPath), 0o755); err != nil {
87
- t.Fatal(err)
88
- }
89
- if err := os.WriteFile(emptyPath, nil, 0o644); err != nil {
90
- t.Fatal(err)
91
- }
92
-
93
- directoryRepo := t.TempDir()
94
- directoryPath := filepath.Join(directoryRepo, ".pi", "roles", "reviewer.md")
95
- if err := os.MkdirAll(directoryPath, 0o755); err != nil {
96
- t.Fatal(err)
97
- }
98
-
99
84
  h := newPiHarness(t)
100
- if err := h.ValidateAgent(context.Background(), emptyRepo, "coder"); err == nil || !strings.Contains(err.Error(), "empty") {
101
- t.Fatalf("empty role error = %v, want empty-role error", err)
85
+ if err := h.ValidateAgent(context.Background(), t.TempDir(), "coder"); err != nil {
86
+ t.Fatalf("missing prompt template error = %v", err)
102
87
  }
103
- if err := h.ValidateAgent(context.Background(), directoryRepo, "reviewer"); err == nil || !strings.Contains(err.Error(), "regular file") {
104
- t.Fatalf("directory role error = %v, want non-regular-role error", err)
88
+ if err := h.ValidateAgent(context.Background(), t.TempDir(), "reviewer"); err != nil {
89
+ t.Fatalf("non-regular prompt template error = %v", err)
105
90
  }
106
91
  if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
107
- t.Fatalf("invalid role files invoked Pi lookup: %v", calls)
92
+ t.Fatalf("prompt-template validation executed Pi: %v", calls)
108
93
  }
109
94
  }
110
95
 
111
- func TestPiValidateAgentRejectsUnsupportedLabelsWithoutAgentDiscovery(t *testing.T) {
96
+ func TestPiValidateAgentAcceptsAnySafePromptTemplateNameWithoutAgentDiscovery(t *testing.T) {
112
97
  fakeDir := strictPiAvailabilityCLI(t)
113
98
  t.Setenv("PATH", fakeDir)
114
99
 
115
100
  h := newPiHarness(t)
116
- for _, label := range []string{"build", "plan", "gpt-4o", ""} {
117
- if err := h.ValidateAgent(context.Background(), "/srv/payments", label); err == nil {
118
- t.Errorf("unsupported agent label %q accepted", label)
101
+ for _, name := range []string{"build", "plan", "gpt-4o"} {
102
+ if err := h.ValidateAgent(context.Background(), "/srv/payments", name); err != nil {
103
+ t.Errorf("safe prompt template name %q rejected: %v", name, err)
119
104
  }
120
105
  }
106
+ if err := h.ValidateAgent(context.Background(), "/srv/payments", ""); err == nil {
107
+ t.Error("empty prompt template name accepted")
108
+ }
121
109
  if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
122
- t.Fatalf("unsupported labels invoked pi instead of rejecting directly: %v", calls)
110
+ t.Fatalf("prompt-template validation executed Pi: %v", calls)
123
111
  }
124
112
  }
125
113
 
@@ -154,15 +142,3 @@ func readAvailabilityCalls(t *testing.T, directory string) []string {
154
142
  }
155
143
  return strings.Split(strings.TrimSuffix(string(data), "\x00"), "\x00")
156
144
  }
157
-
158
- func writePiRole(t *testing.T, repoPath, name, content string) string {
159
- t.Helper()
160
- path := filepath.Join(repoPath, ".pi", "roles", name+".md")
161
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
162
- t.Fatal(err)
163
- }
164
- if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
165
- t.Fatal(err)
166
- }
167
- return path
168
- }
@@ -206,6 +206,20 @@ func (a *adapter) ticketWorkspace(ctx context.Context, spec runner.RunSpec) (str
206
206
 
207
207
  // --- Terminals ---
208
208
 
209
+ // DiscoverTerminal finds an existing live pane by its stable title during
210
+ // explicit projection recovery. It never creates a workspace or pane.
211
+ func (a *adapter) DiscoverTerminal(ctx context.Context, spec runner.RunSpec, title string) (runner.Terminal, bool, error) {
212
+ workspaceID, found, err := a.ticketWorkspace(ctx, spec)
213
+ if err != nil || !found {
214
+ return runner.Terminal{}, false, err
215
+ }
216
+ handle, _, err := a.recoverTerminal(ctx, workspaceID, title)
217
+ if err != nil || handle == "" {
218
+ return runner.Terminal{}, false, err
219
+ }
220
+ return a.FindTerminal(ctx, runner.Terminal{ID: handle, Title: title})
221
+ }
222
+
209
223
  // FindTerminal addresses a persisted pane handle directly and returns it only
210
224
  // when Herdr still has a live agent process in that pane. A pane restored as a
211
225
  // bare shell after a Herdr restart is not a usable agent terminal.
@@ -455,6 +455,26 @@ func TestCreateTerminalUsesPublicPaneIDAndStableLabel(t *testing.T) {
455
455
  }
456
456
  }
457
457
 
458
+ func TestDiscoverTerminalByStableTitle(t *testing.T) {
459
+ live := &fakeClient{
460
+ listing: herdrcli.WorktreeListing{Worktrees: []herdrcli.Worktree{{Branch: "PAY-101", OpenWorkspaceID: "w2"}}},
461
+ tabs: []herdrcli.Tab{{ID: "tab-1", WorkspaceID: "w2", Label: "PAY-101:coding"}},
462
+ panes: []herdrcli.Pane{{ID: "p2", TerminalID: "term-2", WorkspaceID: "w2", TabID: "tab-1", Label: "PAY-101:coding"}},
463
+ pane: herdrcli.Pane{ID: "p2", TerminalID: "term-2", WorkspaceID: "w2", TabID: "tab-1", Label: "PAY-101:coding"},
464
+ info: herdrcli.ProcessInfo{PaneID: "p2", ShellPID: uint32Pointer(1), ForegroundProcesses: []herdrcli.ForegroundProcess{{PID: 42, Name: "opencode"}}},
465
+ }
466
+ discoverer := runner.Runner(newAdapter(live))
467
+ got, found, err := discoverer.(runner.TerminalDiscoverer).DiscoverTerminal(context.Background(), runner.RunSpec{
468
+ RepoName: "payments", RepoPath: repoPath, TicketKey: "PAY-101",
469
+ }, "PAY-101:coding")
470
+ if err != nil || !found || got.ID != "p2" || got.Title != "PAY-101:coding" {
471
+ t.Fatalf("DiscoverTerminal = %+v, %v, %v", got, found, err)
472
+ }
473
+ if len(live.tabCreateCalls) != 0 || len(live.createCalls) != 0 {
474
+ t.Fatalf("terminal discovery created runner resources: tabs=%v worktrees=%v", live.tabCreateCalls, live.createCalls)
475
+ }
476
+ }
477
+
458
478
  func TestFindTerminalAcceptsLiveAgentAndRejectsRestoredShell(t *testing.T) {
459
479
  live := &fakeClient{
460
480
  pane: herdrcli.Pane{ID: "w2:p2", TerminalID: "term_1", WorkspaceID: "w2", Label: "PAY-101:coding"},
@@ -206,6 +206,39 @@ func (a *adapter) CloseTerminal(ctx context.Context, terminal runner.Terminal) e
206
206
  return err
207
207
  }
208
208
 
209
+ // DiscoverTerminal finds a live terminal by stable title during explicit
210
+ // projection recovery. It never creates an environment or terminal.
211
+ func (a *adapter) DiscoverTerminal(ctx context.Context, spec runner.RunSpec, title string) (runner.Terminal, bool, error) {
212
+ repoID, err := a.repoID(ctx, spec.RepoName, spec.RepoPath)
213
+ if err != nil {
214
+ return runner.Terminal{}, false, err
215
+ }
216
+ worktrees, err := a.cli.ListWorktrees(ctx)
217
+ if err != nil {
218
+ return runner.Terminal{}, false, err
219
+ }
220
+ var worktreeID string
221
+ for _, worktree := range worktrees {
222
+ if worktree.RepoID == repoID && worktree.DisplayName == spec.TicketKey {
223
+ worktreeID = worktree.ID
224
+ break
225
+ }
226
+ }
227
+ if worktreeID == "" {
228
+ return runner.Terminal{}, false, nil
229
+ }
230
+ terminals, err := a.cli.ListTerminals(ctx, "id:"+worktreeID)
231
+ if err != nil {
232
+ return runner.Terminal{}, false, err
233
+ }
234
+ for _, terminal := range terminals {
235
+ if terminal.Title == title && terminal.Connected {
236
+ return runner.Terminal{ID: terminal.Handle, Title: title}, true, nil
237
+ }
238
+ }
239
+ return runner.Terminal{}, false, nil
240
+ }
241
+
209
242
  // FindTerminal addresses a persisted handle directly. Normal execution never
210
243
  // lists terminals or rediscovers by title.
211
244
  func (a *adapter) FindTerminal(ctx context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
@@ -39,9 +39,10 @@ func TestSanitizeErr(t *testing.T) {
39
39
 
40
40
  // fakeCLI is the orcacli.Client seam used by the Orca runner tests.
41
41
  type fakeCLI struct {
42
- repos []orcacli.Repo
43
- worktrees []orcacli.Worktree
44
- terminals map[string]orcacli.Terminal
42
+ repos []orcacli.Repo
43
+ worktrees []orcacli.Worktree
44
+ terminals map[string]orcacli.Terminal
45
+ listedTerminals []orcacli.Terminal
45
46
 
46
47
  createdBaseBranch string
47
48
  createdParent string
@@ -82,7 +83,7 @@ func (f *fakeCLI) ShowTerminal(_ context.Context, handle string) (orcacli.Termin
82
83
  }
83
84
  func (f *fakeCLI) SendTerminal(context.Context, string, string) error { return nil }
84
85
  func (f *fakeCLI) ListTerminals(context.Context, string) ([]orcacli.Terminal, error) {
85
- return nil, nil
86
+ return f.listedTerminals, nil
86
87
  }
87
88
  func (f *fakeCLI) CreateTerminal(_ context.Context, _ string, title, command string) (string, error) {
88
89
  f.createN++
@@ -157,6 +158,34 @@ func TestSetEnvironmentStatus(t *testing.T) {
157
158
  }
158
159
  }
159
160
 
161
+ func TestDiscoverTerminalByStableTitle(t *testing.T) {
162
+ fx := &fakeCLI{
163
+ repos: []orcacli.Repo{{ID: "r1", DisplayName: "app", Path: "/srv/app"}},
164
+ worktrees: []orcacli.Worktree{{ID: "wt-PAY-1", RepoID: "r1", DisplayName: "PAY-1"}},
165
+ listedTerminals: []orcacli.Terminal{
166
+ {Handle: "term-other", Title: "PAY-1:other", Connected: true},
167
+ {Handle: "term-node", Title: "PAY-1:implement", Connected: true},
168
+ },
169
+ }
170
+ a, err := New(fx, config.RawValues{})
171
+ if err != nil {
172
+ t.Fatal(err)
173
+ }
174
+ discoverer, ok := a.(runner.TerminalDiscoverer)
175
+ if !ok {
176
+ t.Fatal("Orca runner does not expose recovery terminal discovery")
177
+ }
178
+ got, found, err := discoverer.DiscoverTerminal(context.Background(), runner.RunSpec{
179
+ RepoName: "app", RepoPath: "/srv/app", TicketKey: "PAY-1",
180
+ }, "PAY-1:implement")
181
+ if err != nil || !found || got.ID != "term-node" || got.Title != "PAY-1:implement" {
182
+ t.Fatalf("DiscoverTerminal = %+v, %v, %v", got, found, err)
183
+ }
184
+ if fx.createN != 0 {
185
+ t.Fatalf("terminal discovery created a terminal: %d", fx.createN)
186
+ }
187
+ }
188
+
160
189
  func TestFindTerminalUsesPersistedID(t *testing.T) {
161
190
  fx := &fakeCLI{terminals: map[string]orcacli.Terminal{
162
191
  "term-stored": {Handle: "term-stored", Title: "PAY-1:implement", Connected: true},
@@ -56,6 +56,14 @@ type RunSpec struct {
56
56
  //
57
57
  // Terminal titles are stable and minimal: only "<ticket>:<node>" — never
58
58
  // nodeVisitID, workflow name, agent name, or other changing metadata.
59
+ // TerminalDiscoverer is an optional recovery capability. It discovers a
60
+ // currently live run-owned terminal by its stable title without creating a
61
+ // terminal or changing runner state. The core Runner contract remains
62
+ // unchanged for adapters that do not support title discovery.
63
+ type TerminalDiscoverer interface {
64
+ DiscoverTerminal(ctx context.Context, spec RunSpec, title string) (Terminal, bool, error)
65
+ }
66
+
59
67
  type Runner interface {
60
68
  DiscoverRepos(ctx context.Context) ([]RepoCandidate, error)
61
69
  ValidateRepo(ctx context.Context, name, path string) error
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-flow",
3
- "version": "0.2.4-alpha",
3
+ "version": "0.2.5-alpha",
4
4
  "description": "Graph-based agent workflow engine — tracker-agnostic, pluggable runners",
5
5
  "bin": {
6
6
  "relay-flow": "./bin/relay-flow.js",