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.
- package/README.md +20 -15
- package/cmd/relay-flow/backend_selection_test.go +149 -0
- package/cmd/relay-flow/main.go +94 -13
- package/cmd/relay-flow/scenario_test.go +19 -2
- package/cmd/relay-flow/serve.go +98 -19
- package/cmd/relay-flow/serve_recovery_test.go +100 -0
- package/cmd/relay-flow/temporal_init.go +170 -0
- package/cmd/relay-flow/temporal_init_test.go +217 -0
- package/cmd/relay-flow/temporal_report_test.go +733 -0
- package/examples/config-reference.yaml +2 -2
- package/examples/minimal-beads-task-workflow.yaml +2 -1
- package/examples/workflow-reference.yaml +2 -1
- package/go.mod +37 -16
- package/go.sum +129 -61
- package/internal/config/machine.go +33 -1
- package/internal/config/machine_test.go +76 -0
- package/internal/execution/goworkflows/engine.go +13 -38
- package/internal/execution/goworkflows/projection.go +47 -464
- package/internal/execution/projection/projection.go +867 -0
- package/internal/execution/projection/projection_test.go +347 -0
- package/internal/execution/temporal/activities.go +567 -0
- package/internal/execution/temporal/engine.go +384 -0
- package/internal/execution/temporal/engine_test.go +277 -0
- package/internal/execution/temporal/interpreter.go +736 -0
- package/internal/execution/temporal/operations.go +455 -0
- package/internal/execution/temporal/operations_test.go +101 -0
- package/internal/execution/temporal/recovery.go +194 -0
- package/internal/execution/temporal/recovery_runtime.go +41 -0
- package/internal/execution/temporal/recovery_test.go +102 -0
- package/internal/execution/temporal/snapshot_restart_test.go +72 -0
- package/internal/execution/temporal/spike_test.go +934 -0
- package/internal/execution/temporal/visibility_lag_test.go +415 -0
- package/internal/harness/opencode/opencode.go +3 -1
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/pi/pi.go +49 -46
- package/internal/harness/pi/pi_test.go +26 -10
- package/internal/harness/pi/prompt_test.go +30 -1
- package/internal/harness/pi/validation_test.go +27 -51
- package/internal/runner/herdr/herdr.go +14 -0
- package/internal/runner/herdr/herdr_test.go +20 -0
- package/internal/runner/orca/orca.go +33 -0
- package/internal/runner/orca/orca_test.go +33 -4
- package/internal/runner/runner.go +8 -0
- 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
|
|
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
|
|
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, "--
|
|
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
|
|
157
|
+
func TestBuildCommandDoesNotValidatePromptTemplateFile(t *testing.T) {
|
|
149
158
|
spec := launchSpec(t)
|
|
150
159
|
spec.Agent = "reviewer"
|
|
151
|
-
|
|
152
|
-
if err
|
|
153
|
-
t.Fatalf("
|
|
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:-}" = "--
|
|
306
|
+
if [ "${1:-}" = "--prompt-template" ]; then
|
|
294
307
|
[ "$#" -ge 3 ] || exit 2
|
|
295
|
-
|
|
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
|
|
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("
|
|
43
|
+
t.Fatalf("prompt template rejected: %v", err)
|
|
45
44
|
}
|
|
46
45
|
if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
|
|
47
|
-
|
|
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
|
|
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
|
|
58
|
-
t.Fatalf("missing
|
|
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("
|
|
61
|
+
t.Fatalf("prompt-template validation executed Pi: %v", calls)
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
func
|
|
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 _,
|
|
71
|
-
if err := h.ValidateAgent(context.Background(), t.TempDir(),
|
|
72
|
-
t.Errorf("unsafe
|
|
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
|
|
76
|
+
t.Fatalf("unsafe prompt template names invoked Pi lookup: %v", calls)
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
func
|
|
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(),
|
|
101
|
-
t.Fatalf("
|
|
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(),
|
|
104
|
-
t.Fatalf("
|
|
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("
|
|
92
|
+
t.Fatalf("prompt-template validation executed Pi: %v", calls)
|
|
108
93
|
}
|
|
109
94
|
}
|
|
110
95
|
|
|
111
|
-
func
|
|
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 _,
|
|
117
|
-
if err := h.ValidateAgent(context.Background(), "/srv/payments",
|
|
118
|
-
t.Errorf("
|
|
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("
|
|
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
|
|
43
|
-
worktrees
|
|
44
|
-
terminals
|
|
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
|
|
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
|