relay-flow 0.2.10-alpha → 0.3.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.
- package/README.md +6 -6
- package/cmd/relay-flow/main.go +114 -19
- package/cmd/relay-flow/observability_test.go +118 -6
- package/internal/execution/goworkflows/activities.go +11 -6
- package/internal/execution/goworkflows/engine_test.go +52 -0
- package/internal/execution/goworkflows/fakes_test.go +14 -7
- package/internal/execution/goworkflows/interpreter.go +11 -7
- package/internal/execution/goworkflows/node_runtime_test.go +8 -8
- package/internal/execution/temporal/activities.go +11 -6
- package/internal/execution/temporal/end_cleanup_order_test.go +147 -0
- package/internal/execution/temporal/interpreter.go +11 -7
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/pi/pi.go +18 -8
- package/internal/harness/pi/pi_test.go +1 -1
- package/internal/harness/pi/prompt_test.go +2 -2
- package/internal/runner/clean.go +38 -0
- package/internal/runner/clean_test.go +84 -0
- package/internal/runner/herdr/herdr.go +24 -10
- package/internal/runner/herdr/herdr_test.go +66 -0
- package/internal/runner/orca/orca.go +16 -2
- package/internal/runner/orca/orca_test.go +103 -1
- package/package.json +1 -1
|
@@ -162,7 +162,8 @@ func (a *Activities) LoadNodeRuntime(ctx context.Context, id run.ID, node string
|
|
|
162
162
|
|
|
163
163
|
// EnsureNodeRuntime uses only persisted terminal/session IDs on the normal
|
|
164
164
|
// path. A live terminal is rebound to the new visit; otherwise EnsureTerminal
|
|
165
|
-
// creates a replacement and its direct ID is persisted immediately.
|
|
165
|
+
// creates a replacement and its direct ID is persisted immediately. A stored
|
|
166
|
+
// session receives feedback whether its terminal is reused or replaced.
|
|
166
167
|
func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, repoPath string, spec harness.LaunchSpec, rt NodeRuntime) (NodeRuntime, error) {
|
|
167
168
|
a.runtimeMu.Lock()
|
|
168
169
|
defer a.runtimeMu.Unlock()
|
|
@@ -204,6 +205,10 @@ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, rep
|
|
|
204
205
|
rt.TerminalID = currentRuntime.TerminalID
|
|
205
206
|
rt.SessionID = currentRuntime.SessionID
|
|
206
207
|
spec.ResumeID = rt.SessionID
|
|
208
|
+
promptKind := harness.PromptInitial
|
|
209
|
+
if rt.SessionID != "" {
|
|
210
|
+
promptKind = harness.PromptFeedback
|
|
211
|
+
}
|
|
207
212
|
stored := runner.Terminal{ID: rt.TerminalID, Title: spec.Title}
|
|
208
213
|
terminal, live, err := a.Runner.FindTerminal(ctx, stored)
|
|
209
214
|
if err != nil {
|
|
@@ -218,7 +223,7 @@ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, rep
|
|
|
218
223
|
// Same-visit retry/restart is silent: do not render, build, or send.
|
|
219
224
|
return rt, nil
|
|
220
225
|
}
|
|
221
|
-
prompt, err := a.Harness.RenderPrompt(
|
|
226
|
+
prompt, err := a.Harness.RenderPrompt(promptKind, spec.PromptData, spec.NudgePrompt)
|
|
222
227
|
if err != nil {
|
|
223
228
|
return NodeRuntime{}, err
|
|
224
229
|
}
|
|
@@ -232,14 +237,14 @@ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, rep
|
|
|
232
237
|
}
|
|
233
238
|
}
|
|
234
239
|
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
// node nudge; a new visit includes it.
|
|
240
|
+
// A fresh session receives the initial prompt; a stored session receives
|
|
241
|
+
// feedback even when its terminal must be replaced. Same-visit
|
|
242
|
+
// replacements omit the node nudge; a new visit includes it.
|
|
238
243
|
nudge := ""
|
|
239
244
|
if rt.NodeVisitID == "" || revisit {
|
|
240
245
|
nudge = spec.NudgePrompt
|
|
241
246
|
}
|
|
242
|
-
spec.Prompt, err = a.Harness.RenderPrompt(
|
|
247
|
+
spec.Prompt, err = a.Harness.RenderPrompt(promptKind, spec.PromptData, nudge)
|
|
243
248
|
if err != nil {
|
|
244
249
|
return NodeRuntime{}, err
|
|
245
250
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
package temporal
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"testing"
|
|
7
|
+
"time"
|
|
8
|
+
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
13
|
+
"go.temporal.io/sdk/testsuite"
|
|
14
|
+
temporalworkflow "go.temporal.io/sdk/workflow"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
// The workflow uses a start-to-end graph so the test isolates end cleanup;
|
|
18
|
+
// terminalLive models the persisted node terminal that finalization would close.
|
|
19
|
+
type endCleanupActivities struct {
|
|
20
|
+
events []string
|
|
21
|
+
cleanupDirty bool
|
|
22
|
+
terminalLive bool
|
|
23
|
+
completionObserved bool
|
|
24
|
+
completionObservedOnFailure bool
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func (a *endCleanupActivities) EnsureMailboxes(context.Context, run.Work, []task.MailboxSpec) (map[string]task.Mailbox, error) {
|
|
28
|
+
return map[string]task.Mailbox{}, nil
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func (a *endCleanupActivities) ValidateAgents(context.Context, string, []string) error {
|
|
32
|
+
return nil
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func (a *endCleanupActivities) ProjectionUpsertStep(context.Context, run.StepEntry) error {
|
|
36
|
+
return nil
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func (a *endCleanupActivities) ApplyTaskConfig(context.Context, run.Work, string, *task.Mailbox, map[string]any) error {
|
|
40
|
+
return nil
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func (a *endCleanupActivities) EnsureEnvironment(context.Context, run.Work, string) (runner.Environment, error) {
|
|
44
|
+
return runner.Environment{}, nil
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func (a *endCleanupActivities) SetEnvironmentStatus(context.Context, run.Work, string, string) error {
|
|
48
|
+
return nil
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func (a *endCleanupActivities) CleanupRun(context.Context, run.Work, string) error {
|
|
52
|
+
if a.cleanupDirty {
|
|
53
|
+
a.cleanupDirty = false
|
|
54
|
+
a.completionObservedOnFailure = a.completionObserved
|
|
55
|
+
if a.terminalLive {
|
|
56
|
+
a.events = append(a.events, "cleanup-failed-terminal-live")
|
|
57
|
+
} else {
|
|
58
|
+
a.events = append(a.events, "cleanup-failed-terminal-closed")
|
|
59
|
+
}
|
|
60
|
+
return errors.New("ticket checkout is dirty; commit required before runner cleanup")
|
|
61
|
+
}
|
|
62
|
+
a.events = append(a.events, "cleanup")
|
|
63
|
+
return nil
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func (a *endCleanupActivities) FinalizeNodeRuntimes(context.Context, run.Work, string, run.RuntimePolicy) error {
|
|
67
|
+
if a.terminalLive {
|
|
68
|
+
a.terminalLive = false
|
|
69
|
+
a.events = append(a.events, "finalize-terminal-closed")
|
|
70
|
+
} else {
|
|
71
|
+
a.events = append(a.events, "finalize-terminal-already-closed")
|
|
72
|
+
}
|
|
73
|
+
return nil
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func (a *endCleanupActivities) ProjectionUpdateRetry(context.Context, run.ID, *run.RetryStatus) error {
|
|
77
|
+
return nil
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func (a *endCleanupActivities) ProjectionUpdateState(_ context.Context, _ run.ID, state run.State, _ string, _ *time.Time) error {
|
|
81
|
+
if state == run.StateCompleted {
|
|
82
|
+
a.completionObserved = true
|
|
83
|
+
if a.terminalLive {
|
|
84
|
+
a.events = append(a.events, "completed-terminal-live")
|
|
85
|
+
} else {
|
|
86
|
+
a.events = append(a.events, "completed")
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return nil
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func endCleanupOrderingWorkflow(ctx temporalworkflow.Context, start run.Start) error {
|
|
93
|
+
state := newWorkflowState(ctx, start)
|
|
94
|
+
reason := "canceled"
|
|
95
|
+
return runGraph(ctx, start, state, temporalworkflow.GetSignalChannel(ctx, cancelReasonSignalName), &reason)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func TestTemporalEndCleanupRunsBeforeRuntimeFinalization(t *testing.T) {
|
|
99
|
+
for _, cleanup := range []bool{true, false} {
|
|
100
|
+
t.Run(map[bool]string{true: "enabled", false: "disabled"}[cleanup], func(t *testing.T) {
|
|
101
|
+
activities := &endCleanupActivities{cleanupDirty: cleanup, terminalLive: true}
|
|
102
|
+
var suite testsuite.WorkflowTestSuite
|
|
103
|
+
env := suite.NewTestWorkflowEnvironment()
|
|
104
|
+
env.RegisterWorkflow(endCleanupOrderingWorkflow)
|
|
105
|
+
env.RegisterActivity(activities)
|
|
106
|
+
start := run.Start{
|
|
107
|
+
ID: "repo/cleanup/T-1", Repo: "repo", RepoPath: t.TempDir(),
|
|
108
|
+
Workflow: workflow.Workflow{
|
|
109
|
+
Name: "cleanup", CleanupRunnerOnEnd: cleanup,
|
|
110
|
+
Nodes: map[string]workflow.Node{
|
|
111
|
+
"start": {OnSuccess: []workflow.Route{{Target: "end"}}},
|
|
112
|
+
"end": {},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
Ticket: task.TicketRef{ID: "T-1", Key: "T-1"},
|
|
116
|
+
}
|
|
117
|
+
env.ExecuteWorkflow(endCleanupOrderingWorkflow, start)
|
|
118
|
+
if err := env.GetWorkflowError(); err != nil {
|
|
119
|
+
t.Fatalf("workflow error: %v", err)
|
|
120
|
+
}
|
|
121
|
+
if cleanup {
|
|
122
|
+
if activities.completionObservedOnFailure {
|
|
123
|
+
t.Fatal("run was marked completed while dirty cleanup was retrying")
|
|
124
|
+
}
|
|
125
|
+
want := []string{"cleanup-failed-terminal-live", "cleanup", "finalize-terminal-closed", "completed"}
|
|
126
|
+
if len(activities.events) != len(want) {
|
|
127
|
+
t.Fatalf("activity order = %v, want %v", activities.events, want)
|
|
128
|
+
}
|
|
129
|
+
for i := range want {
|
|
130
|
+
if activities.events[i] != want[i] {
|
|
131
|
+
t.Fatalf("activity order = %v, want %v", activities.events, want)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
want := []string{"finalize-terminal-closed", "completed"}
|
|
136
|
+
if len(activities.events) != len(want) {
|
|
137
|
+
t.Fatalf("cleanup-disabled activity order = %v, want %v", activities.events, want)
|
|
138
|
+
}
|
|
139
|
+
for i := range want {
|
|
140
|
+
if activities.events[i] != want[i] {
|
|
141
|
+
t.Fatalf("cleanup-disabled activity order = %v, want %v", activities.events, want)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -597,6 +597,17 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
|
|
|
597
597
|
if wf.CleanupRunnerOnEnd {
|
|
598
598
|
finalPolicy.KeepTerminalsAlive = false
|
|
599
599
|
}
|
|
600
|
+
// Cleanup must run before finalizing node runtimes when enabled. The
|
|
601
|
+
// runner performs its Git cleanliness check before closing terminals, so a
|
|
602
|
+
// dirty checkout leaves the agent terminal available for a commit while
|
|
603
|
+
// retryActivity waits.
|
|
604
|
+
if wf.CleanupRunnerOnEnd {
|
|
605
|
+
if _, err := retryActivity(ctx, state, work, "", func() (struct{}, error) {
|
|
606
|
+
return executeActivity[struct{}](ctx, activityCleanupRun, work, start.RepoPath)
|
|
607
|
+
}); err != nil {
|
|
608
|
+
return err
|
|
609
|
+
}
|
|
610
|
+
}
|
|
600
611
|
if _, err := retryActivity(ctx, state, work, "", func() (struct{}, error) {
|
|
601
612
|
return executeActivity[struct{}](ctx, activityFinalizeNodeRuntimes, work, start.RepoPath, finalPolicy)
|
|
602
613
|
}); err != nil {
|
|
@@ -605,13 +616,6 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
|
|
|
605
616
|
for node := range state.bindings {
|
|
606
617
|
applyRuntimePolicy(state, node, finalPolicy)
|
|
607
618
|
}
|
|
608
|
-
if wf.CleanupRunnerOnEnd {
|
|
609
|
-
if _, err := retryActivity(ctx, state, work, "", func() (struct{}, error) {
|
|
610
|
-
return executeActivity[struct{}](ctx, activityCleanupRun, work, start.RepoPath)
|
|
611
|
-
}); err != nil {
|
|
612
|
-
return err
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
619
|
now := temporalworkflow.Now(ctx).UTC()
|
|
616
620
|
endStep.Status, endStep.FinishedAt = run.StepSucceeded, &now
|
|
617
621
|
if _, err := retryActivity(ctx, state, work, "end", func() (struct{}, error) {
|
|
@@ -14,7 +14,7 @@ import (
|
|
|
14
14
|
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
15
15
|
)
|
|
16
16
|
|
|
17
|
-
const configuredPlugin = "relay-flow-plugin@0.
|
|
17
|
+
const configuredPlugin = "relay-flow-plugin@0.3.0-alpha"
|
|
18
18
|
|
|
19
19
|
func TestBuildCommandArgv(t *testing.T) {
|
|
20
20
|
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
|
@@ -148,10 +148,11 @@ func (*Harness) FindSession(context.Context, string, string) (harness.Session, b
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
// RenderPrompt renders the selected initial or feedback template and the
|
|
151
|
-
// node's nudge template.
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
// through
|
|
151
|
+
// node's nudge template. Initial prompts use Pi's native prompt-template
|
|
152
|
+
// command syntax; feedback is sent to an existing session and must remain
|
|
153
|
+
// raw so Pi does not expand the full prompt template again. HITL approval is
|
|
154
|
+
// not encoded in the prompt; the Pi extension asks for approval through
|
|
155
|
+
// ctx.ui.select.
|
|
155
156
|
func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData, nudgeTemplate string) (string, error) {
|
|
156
157
|
var tmpl string
|
|
157
158
|
switch kind {
|
|
@@ -168,15 +169,20 @@ func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData,
|
|
|
168
169
|
}
|
|
169
170
|
}
|
|
170
171
|
prompt := appendPrompt(renderTemplate(tmpl, data), renderTemplate(nudgeTemplate, data))
|
|
171
|
-
|
|
172
|
+
if kind == harness.PromptInitial {
|
|
173
|
+
return applyPromptTemplate(data.Agent, prompt), nil
|
|
174
|
+
}
|
|
175
|
+
return prompt, nil
|
|
172
176
|
}
|
|
173
177
|
|
|
174
178
|
// BuildCommand returns the interactive Pi invocation. The runner supplies a
|
|
175
179
|
// PTY for Pi's stdin/stdout; the rendered prompt is the final positional argv
|
|
176
180
|
// value. A named workflow agent adds Pi's --prompt-template option for the
|
|
177
181
|
// project-owned .pi/prompts/<agent>.md file and invokes it with its native
|
|
178
|
-
// slash-command syntax
|
|
179
|
-
//
|
|
182
|
+
// slash-command syntax only for a fresh launch. Resumed sessions receive the
|
|
183
|
+
// raw feedback prompt so Pi does not expand the template a second time. Pi
|
|
184
|
+
// 0.84.1 rejects a bare -- terminator, so none is included. A non-empty
|
|
185
|
+
// ResumeID selects Pi's exact session-id resume option.
|
|
180
186
|
func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
|
|
181
187
|
if err := validateAgentName(spec.Agent); err != nil {
|
|
182
188
|
return runner.Command{}, err
|
|
@@ -215,7 +221,11 @@ func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
|
|
|
215
221
|
if spec.ResumeID != "" {
|
|
216
222
|
args = append(args, "--session-id", spec.ResumeID)
|
|
217
223
|
}
|
|
218
|
-
|
|
224
|
+
prompt := spec.Prompt
|
|
225
|
+
if spec.ResumeID == "" {
|
|
226
|
+
prompt = applyPromptTemplate(spec.Agent, prompt)
|
|
227
|
+
}
|
|
228
|
+
args = append(args, prompt)
|
|
219
229
|
return runner.Command{
|
|
220
230
|
Executable: "pi",
|
|
221
231
|
Args: args,
|
|
@@ -148,7 +148,7 @@ func TestBuildCommandUsesNativePromptTemplate(t *testing.T) {
|
|
|
148
148
|
if err != nil {
|
|
149
149
|
t.Fatalf("BuildCommand(resume): %v", err)
|
|
150
150
|
}
|
|
151
|
-
want = []string{"--name", spec.Title, "--prompt-template", ".pi/prompts/coder.md", "--session-id", spec.ResumeID,
|
|
151
|
+
want = []string{"--name", spec.Title, "--prompt-template", ".pi/prompts/coder.md", "--session-id", spec.ResumeID, spec.Prompt}
|
|
152
152
|
if !reflect.DeepEqual(cmd.Args, want) {
|
|
153
153
|
t.Fatalf("resume Args = %#v, want %#v", cmd.Args, want)
|
|
154
154
|
}
|
|
@@ -97,7 +97,7 @@ func TestPiPromptRetainsDefaultAgentLabel(t *testing.T) {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
func
|
|
100
|
+
func TestPiRenderPromptUsesNativeTemplateCommandForInitialNamedAgent(t *testing.T) {
|
|
101
101
|
h := newPiHarness(t)
|
|
102
102
|
data := harness.PromptData{
|
|
103
103
|
TaskSystem: "jira",
|
|
@@ -120,7 +120,7 @@ func TestPiRenderPromptUsesNativeTemplateCommandForNamedAgent(t *testing.T) {
|
|
|
120
120
|
if err != nil {
|
|
121
121
|
t.Fatalf("RenderPrompt(feedback): %v", err)
|
|
122
122
|
}
|
|
123
|
-
wantFeedback := "
|
|
123
|
+
wantFeedback := "New feedback was added to the comments section of your mailbox subtask PAY-234. Read it.\n\nfeedback nudge"
|
|
124
124
|
if feedback != wantFeedback {
|
|
125
125
|
t.Fatalf("feedback prompt = %q, want %q", feedback, wantFeedback)
|
|
126
126
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
package runner
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"os"
|
|
8
|
+
"os/exec"
|
|
9
|
+
"strings"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// CheckCleanCheckout verifies the ticket-scoped checkout before runner cleanup.
|
|
13
|
+
// A checkout that has already disappeared is an idempotent cleanup success;
|
|
14
|
+
// callers preserve their existing roll-forward behavior in that case.
|
|
15
|
+
func CheckCleanCheckout(ctx context.Context, ticket, checkout string) error {
|
|
16
|
+
if strings.TrimSpace(checkout) == "" {
|
|
17
|
+
return fmt.Errorf("cannot check Git cleanliness for ticket %q: checkout path is empty", ticket)
|
|
18
|
+
}
|
|
19
|
+
if _, err := os.Stat(checkout); err != nil {
|
|
20
|
+
if errors.Is(err, os.ErrNotExist) {
|
|
21
|
+
return nil
|
|
22
|
+
}
|
|
23
|
+
return fmt.Errorf("cannot check Git cleanliness for ticket %q at %q: %w", ticket, checkout, err)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
out, err := exec.CommandContext(ctx, "git", "-C", checkout, "status", "--porcelain=v1", "--untracked-files=all").CombinedOutput()
|
|
27
|
+
if err != nil {
|
|
28
|
+
detail := strings.TrimSpace(string(out))
|
|
29
|
+
if detail != "" {
|
|
30
|
+
return fmt.Errorf("cannot check Git cleanliness for ticket %q at %q: %w: %s", ticket, checkout, err, detail)
|
|
31
|
+
}
|
|
32
|
+
return fmt.Errorf("cannot check Git cleanliness for ticket %q at %q: %w", ticket, checkout, err)
|
|
33
|
+
}
|
|
34
|
+
if len(out) != 0 {
|
|
35
|
+
return fmt.Errorf("ticket %q checkout is dirty; commit required before runner cleanup", ticket)
|
|
36
|
+
}
|
|
37
|
+
return nil
|
|
38
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
package runner
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"os"
|
|
6
|
+
"os/exec"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestCheckCleanCheckoutRejectsGitChanges(t *testing.T) {
|
|
13
|
+
cases := map[string]func(t *testing.T, repo string){
|
|
14
|
+
"untracked": func(t *testing.T, repo string) {
|
|
15
|
+
writeFile(t, filepath.Join(repo, "untracked.txt"), "new\n")
|
|
16
|
+
},
|
|
17
|
+
"unstaged": func(t *testing.T, repo string) {
|
|
18
|
+
writeFile(t, filepath.Join(repo, "tracked.txt"), "changed\n")
|
|
19
|
+
},
|
|
20
|
+
"staged": func(t *testing.T, repo string) {
|
|
21
|
+
writeFile(t, filepath.Join(repo, "tracked.txt"), "changed\n")
|
|
22
|
+
runGit(t, repo, "add", "tracked.txt")
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
for name, makeDirty := range cases {
|
|
26
|
+
t.Run(name, func(t *testing.T) {
|
|
27
|
+
repo := newCleanCheckout(t)
|
|
28
|
+
makeDirty(t, repo)
|
|
29
|
+
err := CheckCleanCheckout(context.Background(), "PAY-101", repo)
|
|
30
|
+
if err == nil || !strings.Contains(err.Error(), "PAY-101") || !strings.Contains(err.Error(), "commit required") {
|
|
31
|
+
t.Fatalf("CheckCleanCheckout error = %v, want ticket and commit-required message", err)
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func TestCheckCleanCheckoutAcceptsCleanAndMissingCheckouts(t *testing.T) {
|
|
38
|
+
repo := newCleanCheckout(t)
|
|
39
|
+
if err := CheckCleanCheckout(context.Background(), "PAY-101", repo); err != nil {
|
|
40
|
+
t.Fatalf("clean checkout returned %v", err)
|
|
41
|
+
}
|
|
42
|
+
if err := CheckCleanCheckout(context.Background(), "PAY-101", filepath.Join(t.TempDir(), "removed")); err != nil {
|
|
43
|
+
t.Fatalf("missing checkout returned %v, want idempotent success", err)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func TestCheckCleanCheckoutPropagatesGitStatusFailure(t *testing.T) {
|
|
48
|
+
dir := t.TempDir()
|
|
49
|
+
err := CheckCleanCheckout(context.Background(), "PAY-101", dir)
|
|
50
|
+
if err == nil {
|
|
51
|
+
t.Fatal("non-Git checkout was treated as clean")
|
|
52
|
+
}
|
|
53
|
+
if strings.Contains(err.Error(), "commit required") {
|
|
54
|
+
t.Fatalf("Git status failure was reported as dirty state: %v", err)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func newCleanCheckout(t *testing.T) string {
|
|
59
|
+
t.Helper()
|
|
60
|
+
repo := t.TempDir()
|
|
61
|
+
runGit(t, repo, "init", "-q", "-b", "main")
|
|
62
|
+
runGit(t, repo, "config", "user.email", "relay-flow@example.invalid")
|
|
63
|
+
runGit(t, repo, "config", "user.name", "Relay Flow")
|
|
64
|
+
writeFile(t, filepath.Join(repo, "tracked.txt"), "clean\n")
|
|
65
|
+
runGit(t, repo, "add", "tracked.txt")
|
|
66
|
+
runGit(t, repo, "commit", "-qm", "initial")
|
|
67
|
+
return repo
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func writeFile(t *testing.T, path, contents string) {
|
|
71
|
+
t.Helper()
|
|
72
|
+
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
|
|
73
|
+
t.Fatal(err)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func runGit(t *testing.T, repo string, args ...string) {
|
|
78
|
+
t.Helper()
|
|
79
|
+
cmd := exec.Command("git", args...)
|
|
80
|
+
cmd.Dir = repo
|
|
81
|
+
if output, err := cmd.CombinedOutput(); err != nil {
|
|
82
|
+
t.Fatalf("git %v: %v: %s", args, err, output)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -282,24 +282,24 @@ func (*adapter) SetEnvironmentStatus(context.Context, runner.Environment, string
|
|
|
282
282
|
// ticketWorkspace resolves the ticket's currently open worktree workspace
|
|
283
283
|
// without creating anything. found is false when the repository, the ticket
|
|
284
284
|
// checkout, or its workspace is absent, which lets cleanup roll forward.
|
|
285
|
-
func (a *adapter) ticketWorkspace(ctx context.Context, spec runner.RunSpec) (string, bool, error) {
|
|
285
|
+
func (a *adapter) ticketWorkspace(ctx context.Context, spec runner.RunSpec) (string, string, bool, error) {
|
|
286
286
|
repoPath := normalizePath(spec.RepoPath)
|
|
287
287
|
if repoPath == "" {
|
|
288
|
-
return "", false, nil
|
|
288
|
+
return "", "", false, nil
|
|
289
289
|
}
|
|
290
290
|
listing, err := a.cli.WorktreeList(ctx, repoPath)
|
|
291
291
|
if err != nil {
|
|
292
292
|
if errors.Is(err, herdrcli.ErrNotGitWorktree) || errors.Is(err, herdrcli.ErrWorktreeNotFound) {
|
|
293
|
-
return "", false, nil
|
|
293
|
+
return "", "", false, nil
|
|
294
294
|
}
|
|
295
|
-
return "", false, err
|
|
295
|
+
return "", "", false, err
|
|
296
296
|
}
|
|
297
297
|
for _, worktree := range listing.Worktrees {
|
|
298
298
|
if worktree.Branch == spec.TicketKey && worktree.OpenWorkspaceID != "" {
|
|
299
|
-
return worktree.OpenWorkspaceID, true, nil
|
|
299
|
+
return worktree.OpenWorkspaceID, normalizePath(worktree.Path), true, nil
|
|
300
300
|
}
|
|
301
301
|
}
|
|
302
|
-
return "", false, nil
|
|
302
|
+
return "", "", false, nil
|
|
303
303
|
}
|
|
304
304
|
|
|
305
305
|
// --- Terminals ---
|
|
@@ -307,7 +307,7 @@ func (a *adapter) ticketWorkspace(ctx context.Context, spec runner.RunSpec) (str
|
|
|
307
307
|
// DiscoverTerminal finds an existing live pane by its stable title during
|
|
308
308
|
// explicit projection recovery. It never creates a workspace or pane.
|
|
309
309
|
func (a *adapter) DiscoverTerminal(ctx context.Context, spec runner.RunSpec, title string) (runner.Terminal, bool, error) {
|
|
310
|
-
workspaceID, found, err := a.ticketWorkspace(ctx, spec)
|
|
310
|
+
workspaceID, _, found, err := a.ticketWorkspace(ctx, spec)
|
|
311
311
|
if err != nil || !found {
|
|
312
312
|
return runner.Terminal{}, false, err
|
|
313
313
|
}
|
|
@@ -549,7 +549,7 @@ func (a *adapter) CloseTerminal(ctx context.Context, terminal runner.Terminal) e
|
|
|
549
549
|
func (a *adapter) CloseTerminals(ctx context.Context, spec runner.RunSpec) error {
|
|
550
550
|
attrs := []any{"ticket", spec.TicketKey, "runID", string(spec.RunID)}
|
|
551
551
|
logCall("close-terminals", attrs...)
|
|
552
|
-
workspaceID, found, err := a.ticketWorkspace(ctx, spec)
|
|
552
|
+
workspaceID, _, found, err := a.ticketWorkspace(ctx, spec)
|
|
553
553
|
if err != nil {
|
|
554
554
|
logOutcome("close-terminals", "error", attrs...)
|
|
555
555
|
return err
|
|
@@ -606,15 +606,29 @@ func (a *adapter) CloseTerminals(ctx context.Context, spec runner.RunSpec) error
|
|
|
606
606
|
|
|
607
607
|
// CleanupRun releases the runner-owned resources for the run: node panes and
|
|
608
608
|
// the ticket workspace. The Git worktree, its branch, and its files are
|
|
609
|
-
// deliberately preserved; a later run reopens the same checkout.
|
|
609
|
+
// deliberately preserved; a later run reopens the same checkout. The ticket
|
|
610
|
+
// checkout must be clean before the workspace is closed.
|
|
610
611
|
func (a *adapter) CleanupRun(ctx context.Context, spec runner.RunSpec) error {
|
|
611
612
|
attrs := []any{"ticket", spec.TicketKey, "runID", string(spec.RunID)}
|
|
612
613
|
logCall("cleanup-run", attrs...)
|
|
614
|
+
workspaceID, checkout, found, err := a.ticketWorkspace(ctx, spec)
|
|
615
|
+
if err != nil {
|
|
616
|
+
logOutcome("cleanup-run", "error", attrs...)
|
|
617
|
+
return err
|
|
618
|
+
}
|
|
619
|
+
if !found {
|
|
620
|
+
logOutcome("cleanup-run", "no-environment", attrs...)
|
|
621
|
+
return nil
|
|
622
|
+
}
|
|
623
|
+
if err := runner.CheckCleanCheckout(ctx, spec.TicketKey, checkout); err != nil {
|
|
624
|
+
logOutcome("cleanup-run", "error", attrs...)
|
|
625
|
+
return err
|
|
626
|
+
}
|
|
613
627
|
if err := a.CloseTerminals(ctx, spec); err != nil {
|
|
614
628
|
logOutcome("cleanup-run", "error", attrs...)
|
|
615
629
|
return err
|
|
616
630
|
}
|
|
617
|
-
workspaceID, found, err
|
|
631
|
+
workspaceID, _, found, err = a.ticketWorkspace(ctx, spec)
|
|
618
632
|
if err != nil {
|
|
619
633
|
logOutcome("cleanup-run", "error", attrs...)
|
|
620
634
|
return err
|
|
@@ -739,6 +739,72 @@ func TestCleanupRunClosesTicketWorkspaceAndKeepsWorktree(t *testing.T) {
|
|
|
739
739
|
}
|
|
740
740
|
}
|
|
741
741
|
|
|
742
|
+
func TestCleanupRunBlocksDirtyCheckoutBeforeClosingHerdrResources(t *testing.T) {
|
|
743
|
+
repo := newGitRepo(t, "main")
|
|
744
|
+
if err := os.WriteFile(filepath.Join(repo, "dirty.txt"), []byte("uncommitted\n"), 0o644); err != nil {
|
|
745
|
+
t.Fatal(err)
|
|
746
|
+
}
|
|
747
|
+
cli := cleanupClientAt(repo)
|
|
748
|
+
spec := runSpec()
|
|
749
|
+
spec.RepoPath = repo
|
|
750
|
+
if err := newAdapter(cli).CleanupRun(context.Background(), spec); err == nil || !strings.Contains(err.Error(), "commit required") {
|
|
751
|
+
t.Fatalf("CleanupRun error = %v, want commit-required dirty-check error", err)
|
|
752
|
+
}
|
|
753
|
+
if len(cli.closedPanes) != 0 || len(cli.closedWorkspaces) != 0 {
|
|
754
|
+
t.Fatalf("dirty cleanup touched Herdr resources: panes=%v workspaces=%v", cli.closedPanes, cli.closedWorkspaces)
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
func TestCleanupRunAllowsCleanCheckoutAndLaterRetry(t *testing.T) {
|
|
759
|
+
repo := newGitRepo(t, "main")
|
|
760
|
+
dirty := filepath.Join(repo, "dirty.txt")
|
|
761
|
+
if err := os.WriteFile(dirty, []byte("uncommitted\n"), 0o644); err != nil {
|
|
762
|
+
t.Fatal(err)
|
|
763
|
+
}
|
|
764
|
+
cli := cleanupClientAt(repo)
|
|
765
|
+
spec := runSpec()
|
|
766
|
+
spec.RepoPath = repo
|
|
767
|
+
a := newAdapter(cli)
|
|
768
|
+
if err := a.CleanupRun(context.Background(), spec); err == nil {
|
|
769
|
+
t.Fatal("dirty CleanupRun succeeded")
|
|
770
|
+
}
|
|
771
|
+
if err := os.Remove(dirty); err != nil {
|
|
772
|
+
t.Fatal(err)
|
|
773
|
+
}
|
|
774
|
+
if err := a.CleanupRun(context.Background(), spec); err != nil {
|
|
775
|
+
t.Fatalf("clean retry CleanupRun = %v", err)
|
|
776
|
+
}
|
|
777
|
+
if len(cli.closedPanes) != 1 || len(cli.closedWorkspaces) != 1 {
|
|
778
|
+
t.Fatalf("clean retry resources: panes=%v workspaces=%v", cli.closedPanes, cli.closedWorkspaces)
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
func TestCleanupRunPropagatesGitStatusFailureBeforeClosingHerdrResources(t *testing.T) {
|
|
783
|
+
repo := t.TempDir()
|
|
784
|
+
cli := cleanupClientAt(repo)
|
|
785
|
+
spec := runSpec()
|
|
786
|
+
spec.RepoPath = repo
|
|
787
|
+
if err := newAdapter(cli).CleanupRun(context.Background(), spec); err == nil {
|
|
788
|
+
t.Fatal("CleanupRun treated Git status failure as clean")
|
|
789
|
+
}
|
|
790
|
+
if len(cli.closedPanes) != 0 || len(cli.closedWorkspaces) != 0 {
|
|
791
|
+
t.Fatalf("Git status failure touched Herdr resources: panes=%v workspaces=%v", cli.closedPanes, cli.closedWorkspaces)
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
func cleanupClientAt(repo string) *fakeClient {
|
|
796
|
+
return &fakeClient{
|
|
797
|
+
listing: herdrcli.WorktreeListing{
|
|
798
|
+
Source: herdrcli.WorktreeSource{RepoName: "payments", RepoRoot: repo, SourceCheckoutPath: repo},
|
|
799
|
+
Worktrees: []herdrcli.Worktree{
|
|
800
|
+
{Path: repo, Branch: "PAY-101", IsLinked: true, OpenWorkspaceID: "w2"},
|
|
801
|
+
},
|
|
802
|
+
},
|
|
803
|
+
tabs: []herdrcli.Tab{{ID: "w2:t2", WorkspaceID: "w2", Label: "PAY-101:coding"}},
|
|
804
|
+
panes: []herdrcli.Pane{{ID: "w2:p2", WorkspaceID: "w2", TabID: "w2:t2", Label: "PAY-101:coding"}},
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
742
808
|
func TestCleanupRollsForwardWhenTicketWorktreeIsGone(t *testing.T) {
|
|
743
809
|
cases := map[string]*fakeClient{
|
|
744
810
|
"repository is not a git work tree": {listingErr: herdrcli.ErrNotGitWorktree},
|
|
@@ -457,14 +457,28 @@ func (a *adapter) CloseTerminals(ctx context.Context, spec runner.RunSpec) error
|
|
|
457
457
|
}
|
|
458
458
|
|
|
459
459
|
// CleanupRun removes all runner-owned run resources: terminals, then the
|
|
460
|
-
// ticket worktree itself.
|
|
460
|
+
// ticket worktree itself. The ticket checkout must be clean before either
|
|
461
|
+
// resource is released.
|
|
461
462
|
func (a *adapter) CleanupRun(ctx context.Context, spec runner.RunSpec) error {
|
|
462
463
|
slog.Debug("orca call", "op", "cleanup-run", "ticket", spec.TicketKey, "runID", string(spec.RunID))
|
|
464
|
+
env, ok, err := a.findEnvironment(ctx, spec)
|
|
465
|
+
if err != nil || !ok {
|
|
466
|
+
if err != nil {
|
|
467
|
+
slog.Info("orca outcome", "op", "cleanup-run", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))
|
|
468
|
+
} else {
|
|
469
|
+
slog.Info("orca outcome", "op", "cleanup-run", "ticket", spec.TicketKey, "result", "no-environment")
|
|
470
|
+
}
|
|
471
|
+
return err
|
|
472
|
+
}
|
|
473
|
+
if err := runner.CheckCleanCheckout(ctx, spec.TicketKey, env.Path); err != nil {
|
|
474
|
+
slog.Info("orca outcome", "op", "cleanup-run", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))
|
|
475
|
+
return err
|
|
476
|
+
}
|
|
463
477
|
if err := a.CloseTerminals(ctx, spec); err != nil {
|
|
464
478
|
slog.Info("orca outcome", "op", "cleanup-run", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))
|
|
465
479
|
return err
|
|
466
480
|
}
|
|
467
|
-
env, ok, err
|
|
481
|
+
env, ok, err = a.findEnvironment(ctx, spec)
|
|
468
482
|
if err != nil || !ok {
|
|
469
483
|
if err != nil {
|
|
470
484
|
slog.Info("orca outcome", "op", "cleanup-run", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))
|