relay-flow 0.2.4-alpha → 0.2.6-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 +29 -18
- package/cmd/relay-flow/backend_selection_test.go +149 -0
- package/cmd/relay-flow/beads_composition_test.go +3 -3
- 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/beads-workflow.yaml +3 -0
- package/examples/config-reference.yaml +7 -3
- 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/activities.go +19 -0
- package/internal/execution/goworkflows/engine.go +13 -38
- package/internal/execution/goworkflows/engine_test.go +1 -1
- package/internal/execution/goworkflows/node_runtime_test.go +113 -4
- 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 +586 -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/harness.go +5 -0
- package/internal/harness/opencode/opencode.go +14 -3
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/opencode/task_env_test.go +57 -0
- package/internal/harness/pi/pi.go +58 -47
- package/internal/harness/pi/pi_test.go +26 -10
- package/internal/harness/pi/prompt_test.go +30 -1
- package/internal/harness/pi/task_env_test.go +51 -0
- package/internal/harness/pi/validation_test.go +27 -51
- package/internal/repo/service.go +18 -8
- 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/internal/task/beads/agent_env_test.go +52 -0
- package/internal/task/beads/beads.go +87 -7
- package/internal/task/beads/beads_test.go +78 -9
- package/internal/task/beads/repo_composition_test.go +47 -3
- package/internal/task/factory.go +31 -2
- package/internal/task/task.go +10 -0
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Package pi is the built-in launch-time Harness adapter for the Pi coding
|
|
2
2
|
// agent. Pi supplies one built-in coding agent, represented by the logical
|
|
3
|
-
// relay-flow agent name "default", plus
|
|
4
|
-
// .pi/
|
|
3
|
+
// relay-flow agent name "default", plus project-owned prompt templates under
|
|
4
|
+
// .pi/prompts. The runtime extension is installed by the user and owns report
|
|
5
5
|
// parsing and delivery.
|
|
6
6
|
package pi
|
|
7
7
|
|
|
@@ -24,7 +24,9 @@ const (
|
|
|
24
24
|
defaultInitialPrompt = `Task system: {{taskSystem}}
|
|
25
25
|
Use the {{taskSystem}} tools to read the parent ticket {{ticket}}.
|
|
26
26
|
|
|
27
|
-
Your mailbox is {{mailbox}}. Read its description and comments for node instructions and feedback
|
|
27
|
+
Your mailbox is {{mailbox}}. Read its description and comments for node instructions and feedback.
|
|
28
|
+
|
|
29
|
+
Keep the summary brief, and make the feedback as detailed and actionable as possible for the next agent.`
|
|
28
30
|
defaultFeedbackPrompt = `New feedback was added to the comments section of your mailbox subtask {{mailbox}}. Read it.`
|
|
29
31
|
)
|
|
30
32
|
|
|
@@ -97,11 +99,11 @@ func New(cfg ...Config) *Harness {
|
|
|
97
99
|
// in each repository.
|
|
98
100
|
func (*Harness) SetupRepo(context.Context, string) error { return nil }
|
|
99
101
|
|
|
100
|
-
// ValidateAgent
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
func (*Harness) ValidateAgent(_ context.Context,
|
|
104
|
-
if
|
|
102
|
+
// ValidateAgent checks the Pi executable and the prompt-template command name.
|
|
103
|
+
// Pi owns prompt-template discovery; relay-flow does not inspect or register
|
|
104
|
+
// the user's project-owned .pi/prompts files.
|
|
105
|
+
func (*Harness) ValidateAgent(_ context.Context, _ string, agent string) error {
|
|
106
|
+
if err := validateAgentName(agent); err != nil {
|
|
105
107
|
return err
|
|
106
108
|
}
|
|
107
109
|
if _, err := exec.LookPath("pi"); err != nil {
|
|
@@ -110,39 +112,33 @@ func (*Harness) ValidateAgent(_ context.Context, repoPath, agent string) error {
|
|
|
110
112
|
return nil
|
|
111
113
|
}
|
|
112
114
|
|
|
113
|
-
func
|
|
115
|
+
func validateAgentName(agent string) error {
|
|
114
116
|
if agent == "default" {
|
|
115
|
-
return
|
|
117
|
+
return nil
|
|
116
118
|
}
|
|
117
119
|
if strings.TrimSpace(agent) == "" || strings.TrimSpace(agent) != agent || strings.ContainsAny(agent, `/\\`) || agent == "." || agent == ".." {
|
|
118
|
-
return
|
|
119
|
-
}
|
|
120
|
-
if repoPath == "" {
|
|
121
|
-
return "", fmt.Errorf("pi: role %q requires a repository path", agent)
|
|
122
|
-
}
|
|
123
|
-
root, err := filepath.Abs(repoPath)
|
|
124
|
-
if err != nil {
|
|
125
|
-
return "", fmt.Errorf("pi: resolve repository path for role %q: %w", agent, err)
|
|
126
|
-
}
|
|
127
|
-
path := filepath.Join(root, ".pi", "roles", agent+".md")
|
|
128
|
-
info, err := os.Stat(path)
|
|
129
|
-
if err != nil {
|
|
130
|
-
if os.IsNotExist(err) {
|
|
131
|
-
return "", fmt.Errorf("pi: role %q is unavailable; expected %s", agent, path)
|
|
132
|
-
}
|
|
133
|
-
return "", fmt.Errorf("pi: inspect role %q: %w", agent, err)
|
|
120
|
+
return fmt.Errorf("pi: invalid prompt template %q", agent)
|
|
134
121
|
}
|
|
135
|
-
|
|
136
|
-
|
|
122
|
+
return nil
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func promptTemplatePath(agent string) string {
|
|
126
|
+
return filepath.Join(".pi", "prompts", agent+".md")
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func applyPromptTemplate(agent, prompt string) string {
|
|
130
|
+
if agent == "" || agent == "default" {
|
|
131
|
+
return prompt
|
|
137
132
|
}
|
|
138
|
-
|
|
139
|
-
if
|
|
140
|
-
|
|
133
|
+
prefix := "/" + agent
|
|
134
|
+
if prompt == prefix || strings.HasPrefix(prompt, prefix+" ") ||
|
|
135
|
+
strings.HasPrefix(prompt, prefix+"\t") || strings.HasPrefix(prompt, prefix+"\n") {
|
|
136
|
+
return prompt
|
|
141
137
|
}
|
|
142
|
-
if
|
|
143
|
-
return
|
|
138
|
+
if prompt == "" {
|
|
139
|
+
return prefix
|
|
144
140
|
}
|
|
145
|
-
return
|
|
141
|
+
return prefix + " " + prompt
|
|
146
142
|
}
|
|
147
143
|
|
|
148
144
|
// FindSession is intentionally discovery-free. Normal execution resumes only
|
|
@@ -152,8 +148,10 @@ func (*Harness) FindSession(context.Context, string, string) (harness.Session, b
|
|
|
152
148
|
}
|
|
153
149
|
|
|
154
150
|
// RenderPrompt renders the selected initial or feedback template and the
|
|
155
|
-
// node's nudge template.
|
|
156
|
-
//
|
|
151
|
+
// node's nudge template. Named Pi agents are native prompt-template commands,
|
|
152
|
+
// so the complete rendered text is supplied as the command arguments. HITL
|
|
153
|
+
// approval is not encoded in the prompt; the Pi extension asks for approval
|
|
154
|
+
// through ctx.ui.select.
|
|
157
155
|
func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData, nudgeTemplate string) (string, error) {
|
|
158
156
|
var tmpl string
|
|
159
157
|
switch kind {
|
|
@@ -164,18 +162,23 @@ func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData,
|
|
|
164
162
|
default:
|
|
165
163
|
return "", fmt.Errorf("pi: unknown prompt kind %q", kind)
|
|
166
164
|
}
|
|
167
|
-
|
|
165
|
+
if data.Agent != "" {
|
|
166
|
+
if err := validateAgentName(data.Agent); err != nil {
|
|
167
|
+
return "", err
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
prompt := appendPrompt(renderTemplate(tmpl, data), renderTemplate(nudgeTemplate, data))
|
|
171
|
+
return applyPromptTemplate(data.Agent, prompt), nil
|
|
168
172
|
}
|
|
169
173
|
|
|
170
174
|
// BuildCommand returns the interactive Pi invocation. The runner supplies a
|
|
171
175
|
// PTY for Pi's stdin/stdout; the rendered prompt is the final positional argv
|
|
172
|
-
// value. A
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
// resume option.
|
|
176
|
+
// value. A named workflow agent adds Pi's --prompt-template option for the
|
|
177
|
+
// project-owned .pi/prompts/<agent>.md file and invokes it with its native
|
|
178
|
+
// slash-command syntax. Pi 0.84.1 rejects a bare -- terminator, so none is
|
|
179
|
+
// included. A non-empty ResumeID selects Pi's exact session-id resume option.
|
|
176
180
|
func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
|
|
177
|
-
|
|
178
|
-
if err != nil {
|
|
181
|
+
if err := validateAgentName(spec.Agent); err != nil {
|
|
179
182
|
return runner.Command{}, err
|
|
180
183
|
}
|
|
181
184
|
nextSteps, err := json.Marshal(spec.NextSteps)
|
|
@@ -186,7 +189,13 @@ func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
|
|
|
186
189
|
if err != nil {
|
|
187
190
|
return runner.Command{}, err
|
|
188
191
|
}
|
|
189
|
-
env := map[string]string{
|
|
192
|
+
env := map[string]string{}
|
|
193
|
+
// Task-system workspace values are applied first so relay-flow's own
|
|
194
|
+
// variables always win on conflict.
|
|
195
|
+
for key, value := range spec.TaskEnv {
|
|
196
|
+
env[key] = value
|
|
197
|
+
}
|
|
198
|
+
for key, value := range map[string]string{
|
|
190
199
|
"RELAY_FLOW_HOME": root,
|
|
191
200
|
"RELAY_FLOW_RUN_ID": string(spec.RunID),
|
|
192
201
|
"RELAY_FLOW_WORKFLOW": spec.Workflow,
|
|
@@ -196,15 +205,17 @@ func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
|
|
|
196
205
|
"RELAY_FLOW_NODE_TYPE": string(spec.NodeType),
|
|
197
206
|
"RELAY_FLOW_NUDGE_PROMPT": spec.NudgePrompt,
|
|
198
207
|
"RELAY_FLOW_NEXT_STEPS_JSON": string(nextSteps),
|
|
208
|
+
} {
|
|
209
|
+
env[key] = value
|
|
199
210
|
}
|
|
200
211
|
args := []string{"--name", spec.Title}
|
|
201
|
-
if
|
|
202
|
-
args = append(args, "--
|
|
212
|
+
if spec.Agent != "default" {
|
|
213
|
+
args = append(args, "--prompt-template", promptTemplatePath(spec.Agent))
|
|
203
214
|
}
|
|
204
215
|
if spec.ResumeID != "" {
|
|
205
216
|
args = append(args, "--session-id", spec.ResumeID)
|
|
206
217
|
}
|
|
207
|
-
args = append(args, spec.Prompt)
|
|
218
|
+
args = append(args, applyPromptTemplate(spec.Agent, spec.Prompt))
|
|
208
219
|
return runner.Command{
|
|
209
220
|
Executable: "pi",
|
|
210
221
|
Args: args,
|
|
@@ -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{
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
package pi
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"testing"
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
// TestBuildCommandCarriesTaskSystemEnvironment asserts the task-system
|
|
8
|
+
// workspace environment reaches the runner command so agent task commands
|
|
9
|
+
// address the same workspace as relay-flow, and that relay-flow's own
|
|
10
|
+
// variables cannot be overwritten by the task system.
|
|
11
|
+
func TestBuildCommandCarriesTaskSystemEnvironment(t *testing.T) {
|
|
12
|
+
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
|
13
|
+
|
|
14
|
+
spec := launchSpec(t)
|
|
15
|
+
spec.TaskEnv = map[string]string{
|
|
16
|
+
"BEADS_DIR": "/var/lib/beads/payments/.beads",
|
|
17
|
+
"BEADS_DB": "",
|
|
18
|
+
"BD_DB": "",
|
|
19
|
+
"RELAY_FLOW_NODE": "hijacked",
|
|
20
|
+
}
|
|
21
|
+
cmd, err := (&Harness{}).BuildCommand(spec)
|
|
22
|
+
if err != nil {
|
|
23
|
+
t.Fatalf("BuildCommand: %v", err)
|
|
24
|
+
}
|
|
25
|
+
if cmd.Env["BEADS_DIR"] != "/var/lib/beads/payments/.beads" {
|
|
26
|
+
t.Fatalf("BEADS_DIR = %q", cmd.Env["BEADS_DIR"])
|
|
27
|
+
}
|
|
28
|
+
for _, key := range []string{"BEADS_DB", "BD_DB"} {
|
|
29
|
+
value, ok := cmd.Env[key]
|
|
30
|
+
if !ok || value != "" {
|
|
31
|
+
t.Fatalf("%s = %q, present=%v; want empty selector", key, value, ok)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if cmd.Env["RELAY_FLOW_NODE"] != spec.Node {
|
|
35
|
+
t.Fatalf("RELAY_FLOW_NODE = %q, want %q", cmd.Env["RELAY_FLOW_NODE"], spec.Node)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// TestBuildCommandWithoutTaskEnvKeepsRelayVariablesOnly asserts an adapter
|
|
40
|
+
// that exposes no workspace environment adds nothing to the command.
|
|
41
|
+
func TestBuildCommandWithoutTaskEnvKeepsRelayVariablesOnly(t *testing.T) {
|
|
42
|
+
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
|
43
|
+
|
|
44
|
+
cmd, err := (&Harness{}).BuildCommand(launchSpec(t))
|
|
45
|
+
if err != nil {
|
|
46
|
+
t.Fatalf("BuildCommand: %v", err)
|
|
47
|
+
}
|
|
48
|
+
if len(cmd.Env) != 9 {
|
|
49
|
+
t.Fatalf("Env = %#v, want only the nine relay-flow variables", cmd.Env)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -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
|
-
}
|
package/internal/repo/service.go
CHANGED
|
@@ -86,8 +86,8 @@ type RegisterInput struct {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
// Register validates the runner repo, required task values, task-system
|
|
89
|
-
// connectivity, duplicate names, canonical paths, and
|
|
90
|
-
//
|
|
89
|
+
// connectivity, duplicate names, canonical paths, and the task plugin's
|
|
90
|
+
// registration identity before atomically writing machine config.
|
|
91
91
|
func (s *Service) Register(ctx context.Context, input RegisterInput) (Info, error) {
|
|
92
92
|
if input.Name == "" {
|
|
93
93
|
return Info{}, fmt.Errorf("repo: name is required")
|
|
@@ -116,19 +116,29 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (Info, erro
|
|
|
116
116
|
return Info{}, fmt.Errorf("repo %q: required task config key %q is missing", input.Name, k)
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
|
-
// Duplicate
|
|
120
|
-
// against every registered repo
|
|
121
|
-
|
|
119
|
+
// Duplicate registration identity: compute this repo's identity and compare
|
|
120
|
+
// against every registered repo. Most plugins use their physical task scope;
|
|
121
|
+
// Beads includes its derived repository label so distinct repositories may
|
|
122
|
+
// share one workspace safely.
|
|
123
|
+
newScope, err := task.RegistrationKey(s.taskPlugin, task.RepoRegistrationSpec{
|
|
124
|
+
Name: input.Name,
|
|
125
|
+
RootConfig: cfg.TaskConfig,
|
|
126
|
+
RepoConfig: input.TaskConfig,
|
|
127
|
+
})
|
|
122
128
|
if err != nil {
|
|
123
129
|
return Info{}, fmt.Errorf("repo %q: %w", input.Name, err)
|
|
124
130
|
}
|
|
125
131
|
for name, r := range cfg.Repos {
|
|
126
|
-
scope, err := task.
|
|
132
|
+
scope, err := task.RegistrationKey(s.taskPlugin, task.RepoRegistrationSpec{
|
|
133
|
+
Name: name,
|
|
134
|
+
RootConfig: cfg.TaskConfig,
|
|
135
|
+
RepoConfig: r.TaskConfig,
|
|
136
|
+
})
|
|
127
137
|
if err != nil {
|
|
128
|
-
return Info{}, fmt.Errorf("repo %q: derive
|
|
138
|
+
return Info{}, fmt.Errorf("repo %q: derive registration identity of registered repo %q: %w", input.Name, name, err)
|
|
129
139
|
}
|
|
130
140
|
if scope == newScope {
|
|
131
|
-
return Info{}, fmt.Errorf("repo %q: task
|
|
141
|
+
return Info{}, fmt.Errorf("repo %q: task registration identity already used by repo %q", input.Name, name)
|
|
132
142
|
}
|
|
133
143
|
}
|
|
134
144
|
// Runner validates the repo.
|
|
@@ -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) {
|