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.
Files changed (60) hide show
  1. package/README.md +29 -18
  2. package/cmd/relay-flow/backend_selection_test.go +149 -0
  3. package/cmd/relay-flow/beads_composition_test.go +3 -3
  4. package/cmd/relay-flow/main.go +94 -13
  5. package/cmd/relay-flow/scenario_test.go +19 -2
  6. package/cmd/relay-flow/serve.go +98 -19
  7. package/cmd/relay-flow/serve_recovery_test.go +100 -0
  8. package/cmd/relay-flow/temporal_init.go +170 -0
  9. package/cmd/relay-flow/temporal_init_test.go +217 -0
  10. package/cmd/relay-flow/temporal_report_test.go +733 -0
  11. package/examples/beads-workflow.yaml +3 -0
  12. package/examples/config-reference.yaml +7 -3
  13. package/examples/minimal-beads-task-workflow.yaml +2 -1
  14. package/examples/workflow-reference.yaml +2 -1
  15. package/go.mod +37 -16
  16. package/go.sum +129 -61
  17. package/internal/config/machine.go +33 -1
  18. package/internal/config/machine_test.go +76 -0
  19. package/internal/execution/goworkflows/activities.go +19 -0
  20. package/internal/execution/goworkflows/engine.go +13 -38
  21. package/internal/execution/goworkflows/engine_test.go +1 -1
  22. package/internal/execution/goworkflows/node_runtime_test.go +113 -4
  23. package/internal/execution/goworkflows/projection.go +47 -464
  24. package/internal/execution/projection/projection.go +867 -0
  25. package/internal/execution/projection/projection_test.go +347 -0
  26. package/internal/execution/temporal/activities.go +586 -0
  27. package/internal/execution/temporal/engine.go +384 -0
  28. package/internal/execution/temporal/engine_test.go +277 -0
  29. package/internal/execution/temporal/interpreter.go +736 -0
  30. package/internal/execution/temporal/operations.go +455 -0
  31. package/internal/execution/temporal/operations_test.go +101 -0
  32. package/internal/execution/temporal/recovery.go +194 -0
  33. package/internal/execution/temporal/recovery_runtime.go +41 -0
  34. package/internal/execution/temporal/recovery_test.go +102 -0
  35. package/internal/execution/temporal/snapshot_restart_test.go +72 -0
  36. package/internal/execution/temporal/spike_test.go +934 -0
  37. package/internal/execution/temporal/visibility_lag_test.go +415 -0
  38. package/internal/harness/harness.go +5 -0
  39. package/internal/harness/opencode/opencode.go +14 -3
  40. package/internal/harness/opencode/opencode_test.go +1 -1
  41. package/internal/harness/opencode/repo_setup.go +1 -1
  42. package/internal/harness/opencode/task_env_test.go +57 -0
  43. package/internal/harness/pi/pi.go +58 -47
  44. package/internal/harness/pi/pi_test.go +26 -10
  45. package/internal/harness/pi/prompt_test.go +30 -1
  46. package/internal/harness/pi/task_env_test.go +51 -0
  47. package/internal/harness/pi/validation_test.go +27 -51
  48. package/internal/repo/service.go +18 -8
  49. package/internal/runner/herdr/herdr.go +14 -0
  50. package/internal/runner/herdr/herdr_test.go +20 -0
  51. package/internal/runner/orca/orca.go +33 -0
  52. package/internal/runner/orca/orca_test.go +33 -4
  53. package/internal/runner/runner.go +8 -0
  54. package/internal/task/beads/agent_env_test.go +52 -0
  55. package/internal/task/beads/beads.go +87 -7
  56. package/internal/task/beads/beads_test.go +78 -9
  57. package/internal/task/beads/repo_composition_test.go +47 -3
  58. package/internal/task/factory.go +31 -2
  59. package/internal/task/task.go +10 -0
  60. 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 repository-owned role prompts under
4
- // .pi/roles. The runtime extension is installed by the user and owns report
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 accepts Pi's built-in logical agent or a repository role
101
- // prompt. Pi has no named-agent listing API, so custom roles are verified by
102
- // checking for a readable, non-empty .pi/roles/<agent>.md file.
103
- func (*Harness) ValidateAgent(_ context.Context, repoPath, agent string) error {
104
- if _, err := resolveRolePrompt(repoPath, agent); err != nil {
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 resolveRolePrompt(repoPath, agent string) (string, error) {
115
+ func validateAgentName(agent string) error {
114
116
  if agent == "default" {
115
- return "", nil
117
+ return nil
116
118
  }
117
119
  if strings.TrimSpace(agent) == "" || strings.TrimSpace(agent) != agent || strings.ContainsAny(agent, `/\\`) || agent == "." || agent == ".." {
118
- return "", fmt.Errorf("pi: invalid role %q", agent)
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
- if !info.Mode().IsRegular() {
136
- return "", fmt.Errorf("pi: role %q is not a regular file: %s", agent, path)
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
- contents, err := os.ReadFile(path)
139
- if err != nil {
140
- return "", fmt.Errorf("pi: read role %q: %w", agent, err)
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 strings.TrimSpace(string(contents)) == "" {
143
- return "", fmt.Errorf("pi: role %q is empty: %s", agent, path)
138
+ if prompt == "" {
139
+ return prefix
144
140
  }
145
- return path, nil
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. HITL approval is not encoded in the prompt; the Pi
156
- // extension asks for approval through ctx.ui.select.
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
- return appendPrompt(renderTemplate(tmpl, data), renderTemplate(nudgeTemplate, data)), nil
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 custom role adds Pi's --append-system-prompt option with the role
173
- // file from the registered repository. Pi 0.84.1 rejects a bare -- terminator,
174
- // so none is included. A non-empty ResumeID selects Pi's exact session-id
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
- rolePath, err := resolveRolePrompt(spec.RepoPath, spec.Agent)
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 rolePath != "" {
202
- args = append(args, "--append-system-prompt", rolePath)
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 repository role prompt.
23
+ // environment, including an optional native prompt template.
24
24
  func TestBuildCommandUsesStrictPiCLIContract(t *testing.T) {
25
25
  fakeDir, capturePath := strictPiCLI(t)
26
26
  t.Setenv("PATH", fakeDir+string(os.PathListSeparator)+os.Getenv("PATH"))
@@ -123,19 +123,18 @@ func TestBuildCommandKeepsDashPrefixedPromptPositional(t *testing.T) {
123
123
  }
124
124
  }
125
125
 
126
- func TestBuildCommandAddsExistingRolePrompt(t *testing.T) {
126
+ func TestBuildCommandUsesNativePromptTemplate(t *testing.T) {
127
127
  fakeDir, capturePath := strictPiCLI(t)
128
128
  t.Setenv("PATH", fakeDir+string(os.PathListSeparator)+os.Getenv("PATH"))
129
129
  t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
130
130
 
131
131
  spec := launchSpec(t)
132
132
  spec.Agent = "coder"
133
- rolePath := writePiRole(t, spec.RepoPath, spec.Agent, "You are the coder for relay-flow.\n")
134
133
  cmd, err := newPiHarness(t).BuildCommand(spec)
135
134
  if err != nil {
136
135
  t.Fatalf("BuildCommand: %v", err)
137
136
  }
138
- want := []string{"--name", spec.Title, "--append-system-prompt", rolePath, spec.Prompt}
137
+ want := []string{"--name", spec.Title, "--prompt-template", ".pi/prompts/coder.md", "/coder " + spec.Prompt}
139
138
  if !reflect.DeepEqual(cmd.Args, want) {
140
139
  t.Fatalf("Args = %#v, want %#v", cmd.Args, want)
141
140
  }
@@ -143,14 +142,28 @@ func TestBuildCommandAddsExistingRolePrompt(t *testing.T) {
143
142
  if !reflect.DeepEqual(capture.args, want) {
144
143
  t.Fatalf("fake Pi args = %#v, want %#v", capture.args, want)
145
144
  }
145
+
146
+ spec.ResumeID = "session-123"
147
+ cmd, err = newPiHarness(t).BuildCommand(spec)
148
+ if err != nil {
149
+ t.Fatalf("BuildCommand(resume): %v", err)
150
+ }
151
+ want = []string{"--name", spec.Title, "--prompt-template", ".pi/prompts/coder.md", "--session-id", spec.ResumeID, "/coder " + spec.Prompt}
152
+ if !reflect.DeepEqual(cmd.Args, want) {
153
+ t.Fatalf("resume Args = %#v, want %#v", cmd.Args, want)
154
+ }
146
155
  }
147
156
 
148
- func TestBuildCommandRejectsMissingRolePrompt(t *testing.T) {
157
+ func TestBuildCommandDoesNotValidatePromptTemplateFile(t *testing.T) {
149
158
  spec := launchSpec(t)
150
159
  spec.Agent = "reviewer"
151
- _, err := newPiHarness(t).BuildCommand(spec)
152
- if err == nil || !strings.Contains(err.Error(), ".pi/roles/reviewer.md") {
153
- t.Fatalf("missing role error = %v, want role path", err)
160
+ cmd, err := newPiHarness(t).BuildCommand(spec)
161
+ if err != nil {
162
+ t.Fatalf("BuildCommand: %v", err)
163
+ }
164
+ want := []string{"--name", spec.Title, "--prompt-template", ".pi/prompts/reviewer.md", "/reviewer " + spec.Prompt}
165
+ if !reflect.DeepEqual(cmd.Args, want) {
166
+ t.Fatalf("Args = %#v, want %#v", cmd.Args, want)
154
167
  }
155
168
  }
156
169
 
@@ -290,9 +303,12 @@ done
290
303
  [ "${1:-}" = "--name" ] || exit 2
291
304
  [ "$#" -ge 3 ] || exit 2
292
305
  shift 2
293
- if [ "${1:-}" = "--append-system-prompt" ]; then
306
+ if [ "${1:-}" = "--prompt-template" ]; then
294
307
  [ "$#" -ge 3 ] || exit 2
295
- [ -s "$2" ] || exit 2
308
+ case "$2" in
309
+ .pi/prompts/*.md) ;;
310
+ *) exit 2 ;;
311
+ esac
296
312
  shift 2
297
313
  fi
298
314
  if [ "${1:-}" = "--session-id" ]; then
@@ -29,7 +29,7 @@ func TestPiRenderPromptSubstitutesInitialAndFeedbackData(t *testing.T) {
29
29
  if err != nil {
30
30
  t.Fatalf("RenderPrompt(initial): %v", err)
31
31
  }
32
- wantInitial := "Task system: jira\nUse the jira tools to read the parent ticket PAY-101.\n\nYour mailbox is PAY-234. Read its description and comments for node instructions and feedback.\n\nnudge jira|PAY-101|basicFlow|payments|implement|review (when: ready)"
32
+ wantInitial := "Task system: jira\nUse the jira tools to read the parent ticket PAY-101.\n\nYour mailbox is PAY-234. Read its description and comments for node instructions and feedback.\n\nKeep the summary brief, and make the feedback as detailed and actionable as possible for the next agent.\n\nnudge jira|PAY-101|basicFlow|payments|implement|review (when: ready)"
33
33
  if initial != wantInitial {
34
34
  t.Fatalf("initial prompt = %q, want %q", initial, wantInitial)
35
35
  }
@@ -97,6 +97,35 @@ func TestPiPromptRetainsDefaultAgentLabel(t *testing.T) {
97
97
  }
98
98
  }
99
99
 
100
+ func TestPiRenderPromptUsesNativeTemplateCommandForNamedAgent(t *testing.T) {
101
+ h := newPiHarness(t)
102
+ data := harness.PromptData{
103
+ TaskSystem: "jira",
104
+ Ticket: "PAY-101",
105
+ Node: "implement",
106
+ Agent: "coder",
107
+ Mailbox: "PAY-234",
108
+ }
109
+
110
+ initial, err := h.RenderPrompt(harness.PromptInitial, data, "nudge {{node}}")
111
+ if err != nil {
112
+ t.Fatalf("RenderPrompt(initial): %v", err)
113
+ }
114
+ wantInitial := "/coder Task system: jira\nUse the jira tools to read the parent ticket PAY-101.\n\nYour mailbox is PAY-234. Read its description and comments for node instructions and feedback.\n\nKeep the summary brief, and make the feedback as detailed and actionable as possible for the next agent.\n\nnudge implement"
115
+ if initial != wantInitial {
116
+ t.Fatalf("initial prompt = %q, want %q", initial, wantInitial)
117
+ }
118
+
119
+ feedback, err := h.RenderPrompt(harness.PromptFeedback, data, "feedback nudge")
120
+ if err != nil {
121
+ t.Fatalf("RenderPrompt(feedback): %v", err)
122
+ }
123
+ wantFeedback := "/coder New feedback was added to the comments section of your mailbox subtask PAY-234. Read it.\n\nfeedback nudge"
124
+ if feedback != wantFeedback {
125
+ t.Fatalf("feedback prompt = %q, want %q", feedback, wantFeedback)
126
+ }
127
+ }
128
+
100
129
  func TestPiPromptsDoNotIncludeOpenCodeQuestionInstructions(t *testing.T) {
101
130
  h := newPiHarness(t)
102
131
  data := harness.PromptData{
@@ -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 TestPiValidateAgentAcceptsExistingRoleAndPiOnPath(t *testing.T) {
36
+ func TestPiValidateAgentAcceptsNamedPromptTemplateAndPiOnPath(t *testing.T) {
37
37
  fakeDir := strictPiAvailabilityCLI(t)
38
38
  t.Setenv("PATH", fakeDir)
39
39
  repoPath := t.TempDir()
40
- rolePath := writePiRole(t, repoPath, "coder", "You are the coder.\n")
41
40
 
42
41
  h := newPiHarness(t)
43
42
  if err := h.ValidateAgent(context.Background(), repoPath, "coder"); err != nil {
44
- t.Fatalf("existing role %q rejected: %v", rolePath, err)
43
+ t.Fatalf("prompt template rejected: %v", err)
45
44
  }
46
45
  if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
47
- t.Fatalf("role validation unexpectedly executed Pi: %v", calls)
46
+ // ValidateAgent only performs executable lookup; it must not execute Pi.
47
+ t.Fatalf("unexpected Pi execution during prompt-template validation: %v", calls)
48
48
  }
49
49
  }
50
50
 
51
- func TestPiValidateAgentRejectsMissingRoleBeforePiLookup(t *testing.T) {
51
+ func TestPiValidateAgentDoesNotInspectPromptTemplateBeforePiLookup(t *testing.T) {
52
52
  fakeDir := strictPiAvailabilityCLI(t)
53
53
  t.Setenv("PATH", fakeDir)
54
54
 
55
55
  h := newPiHarness(t)
56
56
  err := h.ValidateAgent(context.Background(), t.TempDir(), "reviewer")
57
- if err == nil || !strings.Contains(err.Error(), ".pi/roles/reviewer.md") {
58
- t.Fatalf("missing role error = %v, want role path", err)
57
+ if err != nil {
58
+ t.Fatalf("missing prompt template rejected: %v", err)
59
59
  }
60
60
  if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
61
- t.Fatalf("missing role invoked Pi lookup: %v", calls)
61
+ t.Fatalf("prompt-template validation executed Pi: %v", calls)
62
62
  }
63
63
  }
64
64
 
65
- func TestPiValidateAgentRejectsUnsafeRolePath(t *testing.T) {
65
+ func TestPiValidateAgentRejectsUnsafePromptTemplateName(t *testing.T) {
66
66
  fakeDir := strictPiAvailabilityCLI(t)
67
67
  t.Setenv("PATH", fakeDir)
68
68
 
69
69
  h := newPiHarness(t)
70
- for _, role := range []string{"../coder", "subdir/coder", `subdir\\coder`, " ", "."} {
71
- if err := h.ValidateAgent(context.Background(), t.TempDir(), role); err == nil {
72
- t.Errorf("unsafe role %q accepted", role)
70
+ for _, name := range []string{"../coder", "subdir/coder", `subdir\\coder`, " ", "."} {
71
+ if err := h.ValidateAgent(context.Background(), t.TempDir(), name); err == nil {
72
+ t.Errorf("unsafe prompt template name %q accepted", name)
73
73
  }
74
74
  }
75
75
  if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
76
- t.Fatalf("unsafe roles invoked Pi lookup: %v", calls)
76
+ t.Fatalf("unsafe prompt template names invoked Pi lookup: %v", calls)
77
77
  }
78
78
  }
79
79
 
80
- func TestPiValidateAgentRejectsEmptyAndNonRegularRoleFiles(t *testing.T) {
80
+ func TestPiValidateAgentAcceptsMissingAndNonRegularPromptTemplateFiles(t *testing.T) {
81
81
  fakeDir := strictPiAvailabilityCLI(t)
82
82
  t.Setenv("PATH", fakeDir)
83
83
 
84
- emptyRepo := t.TempDir()
85
- emptyPath := filepath.Join(emptyRepo, ".pi", "roles", "coder.md")
86
- if err := os.MkdirAll(filepath.Dir(emptyPath), 0o755); err != nil {
87
- t.Fatal(err)
88
- }
89
- if err := os.WriteFile(emptyPath, nil, 0o644); err != nil {
90
- t.Fatal(err)
91
- }
92
-
93
- directoryRepo := t.TempDir()
94
- directoryPath := filepath.Join(directoryRepo, ".pi", "roles", "reviewer.md")
95
- if err := os.MkdirAll(directoryPath, 0o755); err != nil {
96
- t.Fatal(err)
97
- }
98
-
99
84
  h := newPiHarness(t)
100
- if err := h.ValidateAgent(context.Background(), emptyRepo, "coder"); err == nil || !strings.Contains(err.Error(), "empty") {
101
- t.Fatalf("empty role error = %v, want empty-role error", err)
85
+ if err := h.ValidateAgent(context.Background(), t.TempDir(), "coder"); err != nil {
86
+ t.Fatalf("missing prompt template error = %v", err)
102
87
  }
103
- if err := h.ValidateAgent(context.Background(), directoryRepo, "reviewer"); err == nil || !strings.Contains(err.Error(), "regular file") {
104
- t.Fatalf("directory role error = %v, want non-regular-role error", err)
88
+ if err := h.ValidateAgent(context.Background(), t.TempDir(), "reviewer"); err != nil {
89
+ t.Fatalf("non-regular prompt template error = %v", err)
105
90
  }
106
91
  if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
107
- t.Fatalf("invalid role files invoked Pi lookup: %v", calls)
92
+ t.Fatalf("prompt-template validation executed Pi: %v", calls)
108
93
  }
109
94
  }
110
95
 
111
- func TestPiValidateAgentRejectsUnsupportedLabelsWithoutAgentDiscovery(t *testing.T) {
96
+ func TestPiValidateAgentAcceptsAnySafePromptTemplateNameWithoutAgentDiscovery(t *testing.T) {
112
97
  fakeDir := strictPiAvailabilityCLI(t)
113
98
  t.Setenv("PATH", fakeDir)
114
99
 
115
100
  h := newPiHarness(t)
116
- for _, label := range []string{"build", "plan", "gpt-4o", ""} {
117
- if err := h.ValidateAgent(context.Background(), "/srv/payments", label); err == nil {
118
- t.Errorf("unsupported agent label %q accepted", label)
101
+ for _, name := range []string{"build", "plan", "gpt-4o"} {
102
+ if err := h.ValidateAgent(context.Background(), "/srv/payments", name); err != nil {
103
+ t.Errorf("safe prompt template name %q rejected: %v", name, err)
119
104
  }
120
105
  }
106
+ if err := h.ValidateAgent(context.Background(), "/srv/payments", ""); err == nil {
107
+ t.Error("empty prompt template name accepted")
108
+ }
121
109
  if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
122
- t.Fatalf("unsupported labels invoked pi instead of rejecting directly: %v", calls)
110
+ t.Fatalf("prompt-template validation executed Pi: %v", calls)
123
111
  }
124
112
  }
125
113
 
@@ -154,15 +142,3 @@ func readAvailabilityCalls(t *testing.T, directory string) []string {
154
142
  }
155
143
  return strings.Split(strings.TrimSuffix(string(data), "\x00"), "\x00")
156
144
  }
157
-
158
- func writePiRole(t *testing.T, repoPath, name, content string) string {
159
- t.Helper()
160
- path := filepath.Join(repoPath, ".pi", "roles", name+".md")
161
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
162
- t.Fatal(err)
163
- }
164
- if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
165
- t.Fatal(err)
166
- }
167
- return path
168
- }
@@ -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 duplicate canonical
90
- // task scope before atomically writing machine config.
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 canonical task scope: compute this repo's scope and compare
120
- // against every registered repo's scope.
121
- newScope, err := task.TaskScopeKey(s.taskPlugin, cfg.TaskConfig, input.TaskConfig)
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.TaskScopeKey(s.taskPlugin, cfg.TaskConfig, r.TaskConfig)
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 scope of registered repo %q: %w", input.Name, name, err)
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 scope already used by repo %q", input.Name, name)
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) {