relay-flow 0.2.11-alpha → 0.3.1-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.go +7 -5
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/opencode/validation_test.go +106 -0
- 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) {
|
|
@@ -88,7 +88,7 @@ func init() {
|
|
|
88
88
|
type Harness struct {
|
|
89
89
|
templates Config
|
|
90
90
|
// listAgents is the test seam; nil → real `opencode agent list`.
|
|
91
|
-
listAgents func(ctx context.Context) ([]string, error)
|
|
91
|
+
listAgents func(ctx context.Context, repoPath string) ([]string, error)
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
// New returns the production Harness.
|
|
@@ -110,7 +110,7 @@ func (h *Harness) SetupRepo(_ context.Context, repoPath string) error {
|
|
|
110
110
|
// ValidateAgent reports whether name is a known OpenCode agent for the
|
|
111
111
|
// repo, per `opencode agent list` (agent names are the unindented first
|
|
112
112
|
// tokens).
|
|
113
|
-
func (h *Harness) ValidateAgent(ctx context.Context,
|
|
113
|
+
func (h *Harness) ValidateAgent(ctx context.Context, repoPath, agent string) error {
|
|
114
114
|
if agent == "" {
|
|
115
115
|
return fmt.Errorf("opencode: agent name is empty")
|
|
116
116
|
}
|
|
@@ -118,7 +118,7 @@ func (h *Harness) ValidateAgent(ctx context.Context, _ string, agent string) err
|
|
|
118
118
|
if list == nil {
|
|
119
119
|
list = listAgents
|
|
120
120
|
}
|
|
121
|
-
names, err := list(ctx)
|
|
121
|
+
names, err := list(ctx, repoPath)
|
|
122
122
|
if err != nil {
|
|
123
123
|
return err
|
|
124
124
|
}
|
|
@@ -236,8 +236,10 @@ func relayFlowHome() (string, error) {
|
|
|
236
236
|
|
|
237
237
|
// listAgents runs `opencode agent list` and returns the agent names
|
|
238
238
|
// (the unindented lines' first tokens).
|
|
239
|
-
func listAgents(ctx context.Context) ([]string, error) {
|
|
240
|
-
|
|
239
|
+
func listAgents(ctx context.Context, repoPath string) ([]string, error) {
|
|
240
|
+
cmd := exec.CommandContext(ctx, "opencode", "agent", "list")
|
|
241
|
+
cmd.Dir = repoPath
|
|
242
|
+
out, err := cmd.Output()
|
|
241
243
|
if err != nil {
|
|
242
244
|
return nil, fmt.Errorf("opencode agent list: %w", err)
|
|
243
245
|
}
|
|
@@ -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.1-alpha"
|
|
18
18
|
|
|
19
19
|
func TestBuildCommandArgv(t *testing.T) {
|
|
20
20
|
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
package opencode
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"runtime"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestValidateAgentPassesRepositoryToListAgentsSeam(t *testing.T) {
|
|
13
|
+
repoPath := t.TempDir()
|
|
14
|
+
var gotRepoPath string
|
|
15
|
+
h := New()
|
|
16
|
+
h.listAgents = func(_ context.Context, path string) ([]string, error) {
|
|
17
|
+
gotRepoPath = path
|
|
18
|
+
return []string{"build"}, nil
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if err := h.ValidateAgent(context.Background(), repoPath, "build"); err != nil {
|
|
22
|
+
t.Fatalf("ValidateAgent: %v", err)
|
|
23
|
+
}
|
|
24
|
+
if gotRepoPath != repoPath {
|
|
25
|
+
t.Fatalf("listAgents repoPath = %q, want %q", gotRepoPath, repoPath)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func TestValidateAgentUsesRepositoryWorkingDirectory(t *testing.T) {
|
|
30
|
+
if runtime.GOOS == "windows" {
|
|
31
|
+
t.Skip("fake opencode uses a POSIX shell")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
repoA := t.TempDir()
|
|
35
|
+
repoB := t.TempDir()
|
|
36
|
+
fakeDir := t.TempDir()
|
|
37
|
+
fake := filepath.Join(fakeDir, "opencode")
|
|
38
|
+
script := `#!/bin/sh
|
|
39
|
+
set -eu
|
|
40
|
+
[ "$#" -eq 2 ] && [ "$1" = "agent" ] && [ "$2" = "list" ] || exit 2
|
|
41
|
+
actual=$(pwd)
|
|
42
|
+
case "$actual" in
|
|
43
|
+
"$OPENCODE_REPO_A")
|
|
44
|
+
printf 'repo-a-agent details\n'
|
|
45
|
+
;;
|
|
46
|
+
"$OPENCODE_REPO_B")
|
|
47
|
+
printf 'repo-b-agent details\n'
|
|
48
|
+
;;
|
|
49
|
+
*)
|
|
50
|
+
printf 'unexpected working directory: %s\n' "$actual" >&2
|
|
51
|
+
exit 3
|
|
52
|
+
;;
|
|
53
|
+
esac
|
|
54
|
+
printf ' ignored-indented\n'
|
|
55
|
+
printf '[ignored-json]\n'
|
|
56
|
+
printf '{"ignored":true}\n'
|
|
57
|
+
`
|
|
58
|
+
if err := os.WriteFile(fake, []byte(script), 0o700); err != nil {
|
|
59
|
+
t.Fatal(err)
|
|
60
|
+
}
|
|
61
|
+
t.Setenv("PATH", fakeDir)
|
|
62
|
+
t.Setenv("OPENCODE_REPO_A", repoA)
|
|
63
|
+
t.Setenv("OPENCODE_REPO_B", repoB)
|
|
64
|
+
|
|
65
|
+
h := New()
|
|
66
|
+
if err := h.ValidateAgent(context.Background(), repoA, "repo-a-agent"); err != nil {
|
|
67
|
+
t.Fatalf("agent from repository A rejected: %v", err)
|
|
68
|
+
}
|
|
69
|
+
if err := h.ValidateAgent(context.Background(), repoB, "repo-b-agent"); err != nil {
|
|
70
|
+
t.Fatalf("agent from repository B rejected: %v", err)
|
|
71
|
+
}
|
|
72
|
+
if err := h.ValidateAgent(context.Background(), repoA, "repo-b-agent"); err == nil {
|
|
73
|
+
t.Fatal("repository B agent accepted while validating repository A")
|
|
74
|
+
}
|
|
75
|
+
if err := h.ValidateAgent(context.Background(), repoB, "repo-a-agent"); err == nil {
|
|
76
|
+
t.Fatal("repository A agent accepted while validating repository B")
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func TestValidateAgentPropagatesListCommandFailure(t *testing.T) {
|
|
81
|
+
if runtime.GOOS == "windows" {
|
|
82
|
+
t.Skip("fake opencode uses a POSIX shell")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
fakeDir := t.TempDir()
|
|
86
|
+
fake := filepath.Join(fakeDir, "opencode")
|
|
87
|
+
script := `#!/bin/sh
|
|
88
|
+
set -eu
|
|
89
|
+
[ "$#" -eq 2 ] && [ "$1" = "agent" ] && [ "$2" = "list" ] || exit 2
|
|
90
|
+
printf 'agent list failed\n' >&2
|
|
91
|
+
exit 17
|
|
92
|
+
`
|
|
93
|
+
if err := os.WriteFile(fake, []byte(script), 0o700); err != nil {
|
|
94
|
+
t.Fatal(err)
|
|
95
|
+
}
|
|
96
|
+
t.Setenv("PATH", fakeDir)
|
|
97
|
+
|
|
98
|
+
h := New()
|
|
99
|
+
err := h.ValidateAgent(context.Background(), t.TempDir(), "build")
|
|
100
|
+
if err == nil {
|
|
101
|
+
t.Fatal("ValidateAgent succeeded after opencode agent list failed")
|
|
102
|
+
}
|
|
103
|
+
if !strings.Contains(err.Error(), "opencode agent list") {
|
|
104
|
+
t.Fatalf("error = %q, want opencode agent list context", err)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -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
|
+
}
|