relay-flow 0.2.5-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 CHANGED
@@ -177,7 +177,13 @@ bd init \
177
177
  # Example external workspace: /var/lib/beads/payments/.beads
178
178
  ```
179
179
 
180
- Register the code repository and its Beads workspace separately. `beadsDir` is required for every Beads repo, must name an existing directory, and is the task scope used to reject duplicate workspace registration:
180
+ For the relay-flow installation used by this repository, the configured Beads workspace is:
181
+
182
+ ```text
183
+ /home/raj/.beads/relay-flow/.beads
184
+ ```
185
+
186
+ Register the code repository and its Beads workspace separately. `beadsDir` is required for every Beads repo and must name an existing directory. It selects the Beads workspace/database; it is not the code repository path or a workflow filter:
181
187
 
182
188
  ```sh
183
189
  # Local/embedded workspace
@@ -193,7 +199,7 @@ relay-flow repo register \
193
199
  --set beadsDir=/var/lib/beads/payments/.beads
194
200
  ```
195
201
 
196
- The registered `--path` remains the code/runner repository. Every `bd` command runs with that path as its working directory and the configured `beadsDir` as `BEADS_DIR`, even when unrelated Beads selector variables exist in the relay-flow environment. Two repos may register different canonical `beadsDir` values; a second repo pointing at the same workspace is rejected. A Beads prefix such as `payments-...` is optional and only makes issue IDs recognizable—it is not a component, workspace selector, poller selector, or database isolation mechanism.
202
+ The registered `--path` remains the code/runner repository. Every `bd` command runs with that path as its working directory and the configured `beadsDir` as `BEADS_DIR`, even when unrelated Beads selector variables exist in the relay-flow environment. Multiple code repositories may share one Beads/Dolt workspace when their derived repository labels differ. Relay-flow derives one reserved label, `repo:<lowercase-registered-name>`, for each repository; names must use letters, numbers, `.`, `_`, or `-`, and be at most 250 characters so the derived label stays within Beads' 255-character limit. The label is not entered in workflow configuration. A parent must carry exactly one such repository label to be eligible for that repo's poller: missing or ambiguous `repo:` labels are ignored before workflow filters and routing. Workflow ownership labels remain independent `wf:<workflow>` labels. A collision between derived labels in the same workspace is rejected before the second repository is registered. A Beads prefix such as `payments-...` is optional and only makes issue IDs recognizable—it is not a component, workspace selector, poller selector, or database isolation mechanism.
197
203
 
198
204
  Beads workflow filters are structured and evaluated in relay-flow. For example, `examples/beads-workflow.yaml` uses the Beads status and issue-type fields:
199
205
 
@@ -211,7 +217,7 @@ Filter values are exact and are not translated between providers.
211
217
 
212
218
  The supported Beads status names are `open`, `in_progress`, `blocked`, `deferred`, `hooked`, and `closed`. The claimed-parent poll uses the canonical active set `open,in_progress,blocked,deferred`; it intentionally does not substitute `hooked` for `deferred`. Omitted lifecycle settings move the parent to `in_progress` at `start`, a work-node mailbox to `in_progress`, and the parent to `closed` at `end` — the same shape as Jira, with Beads-native values. Relay-flow creates one Repo Poller per registered repo, not one poller per workflow. Each poll reads ready top-level parents and relay-owned active parents, deduplicates them, and never routes mailbox children. Claims are permanent `wf:<workflow>` labels.
213
219
 
214
- Beads does not need relay-flow credentials or a Beads-specific poller. In server mode, leave Dolt and Beads server setup running outside relay-flow and point each repo at its own `beadsDir`.
220
+ Beads does not need relay-flow credentials or a Beads-specific poller. In server mode, leave Dolt and Beads server setup running outside relay-flow; each registered code repository still supplies its `beadsDir`, and repositories that share that workspace are isolated by their derived `repo:` labels.
215
221
 
216
222
  ### Submit a workflow
217
223
 
@@ -361,9 +361,9 @@ issue_json() {
361
361
  case "$issue" in
362
362
  demo-parent)
363
363
  if [ -f "$BD_STATE/claimed" ]; then
364
- labels='["wf:beadsComposition"]'
364
+ labels='["repo:payments","wf:beadsComposition"]'
365
365
  else
366
- labels='[]'
366
+ labels='["repo:payments"]'
367
367
  fi
368
368
  printf '[{"id":"demo-parent","title":"Composition parent","status":"%s","issue_type":"epic","priority":1,"labels":%s}]\n' "$status" "$labels"
369
369
  ;;
@@ -385,7 +385,7 @@ if [ "$#" -eq 6 ] && [ "$1" = list ] && [ "$2" = --ready ] && [ "$3" = --no-pare
385
385
  if [ -f "$BD_STATE/claimed" ]; then
386
386
  printf '[]\n'
387
387
  else
388
- printf '[{"id":"demo-parent","title":"Composition parent","status":"open","issue_type":"epic","priority":1,"labels":[]}]\n'
388
+ printf '[{"id":"demo-parent","title":"Composition parent","status":"open","issue_type":"epic","priority":1,"labels":["repo:payments"]}]\n'
389
389
  fi
390
390
  exit 0
391
391
  fi
@@ -4,6 +4,9 @@
4
4
  # relay-flow repo register --name payments --path /work/payments \
5
5
  # --set beadsDir=/work/payments/.beads
6
6
  #
7
+ # Relay-flow derives the reserved repo:payments label from the registered
8
+ # repository name. Parents without exactly one matching repo: label are
9
+ # ignored before workflow filters; wf:<workflow> remains a separate claim.
7
10
  # The Beads prefix is optional and is not a workspace/component selector.
8
11
  name: beadsStoryFlow
9
12
  repos:
@@ -118,7 +118,11 @@ repos:
118
118
  #
119
119
  # Beads uses the shared taskConfig fields (filters, assignee, transitionTo,
120
120
  # templates) but does not accept Jira-only project/component fields. beadsDir
121
- # is required at repo scope and must not be supplied only at root scope.
121
+ # is required at repo scope and must not be supplied only at root scope. The
122
+ # registered code path is the bd working directory; beadsDir selects the
123
+ # workspace/database. Shared workspaces isolate parents with the derived
124
+ # reserved repo:<lowercase-repo-name> label (the registered name may be at
125
+ # most 250 ASCII characters), not with workflow labels.
122
126
  #
123
127
  # Pi variant:
124
128
  # harnessPlugin: pi
@@ -44,6 +44,21 @@ func (a *Activities) runSpec(w run.Work) runner.RunSpec {
44
44
  }
45
45
  }
46
46
 
47
+ // agentEnv returns the repo task system's agent workspace environment, or nil
48
+ // when the adapter exposes none. It is resolved at launch time so no workspace
49
+ // value is carried in durable workflow history.
50
+ func (a *Activities) agentEnv(repoName string) (map[string]string, error) {
51
+ sys, err := a.taskSystem(repoName)
52
+ if err != nil {
53
+ return nil, err
54
+ }
55
+ provider, ok := sys.(task.AgentEnvironment)
56
+ if !ok {
57
+ return nil, nil
58
+ }
59
+ return provider.AgentEnv(), nil
60
+ }
61
+
47
62
  // EnsureMailboxes ensures one mailbox per work node and returns the
48
63
  // node-to-mailbox map.
49
64
  func (a *Activities) EnsureMailboxes(ctx context.Context, w run.Work, specs []task.MailboxSpec) (map[string]task.Mailbox, error) {
@@ -220,6 +235,10 @@ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, rep
220
235
  if err != nil {
221
236
  return err
222
237
  }
238
+ spec.TaskEnv, err = a.agentEnv(nw.Repo)
239
+ if err != nil {
240
+ return err
241
+ }
223
242
  cmd, err := a.Harness.BuildCommand(spec)
224
243
  if err != nil {
225
244
  return err
@@ -71,7 +71,7 @@ func TestMailboxDescriptionAndLaunchPromptAreTaskSystemNeutral(t *testing.T) {
71
71
  if err != nil {
72
72
  t.Fatal(err)
73
73
  }
74
- want := "Task system: " + taskSystem + "\nUse the " + taskSystem + " 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\nReturn the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval."
74
+ want := "Task system: " + taskSystem + "\nUse the " + taskSystem + " 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\nReturn the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval."
75
75
  if prompt != want {
76
76
  t.Fatalf("RenderPrompt(%s) = %q, want %q", taskSystem, prompt, want)
77
77
  }
@@ -9,6 +9,7 @@ import (
9
9
  "testing"
10
10
  "time"
11
11
 
12
+ "github.com/rajpopat27/relay-flow/internal/config"
12
13
  "github.com/rajpopat27/relay-flow/internal/harness"
13
14
  "github.com/rajpopat27/relay-flow/internal/repo"
14
15
  "github.com/rajpopat27/relay-flow/internal/run"
@@ -210,7 +211,7 @@ func TestEnsureNodeRuntimeUsesDirectIDsAndFallsBackFresh(t *testing.T) {
210
211
  }); err != nil {
211
212
  t.Fatal(err)
212
213
  }
213
- a := &Activities{Runner: fr, Harness: fh, Runs: p}
214
+ a := &Activities{Repos: runtimeTestRepos(&runtimeTestTaskSystem{}), Runner: fr, Harness: fh, Runs: p}
214
215
  nw := run.NodeWork{Work: run.Work{RunID: id, Repo: "payments", Workflow: "basic", Parent: task.TicketRef{Key: "PAY-101"}}, Node: "implement", NodeVisitID: "visit-2"}
215
216
  spec := harness.LaunchSpec{RunID: id, NodeVisitID: "visit-2", RepoName: "payments", Workflow: "basic", Ticket: "PAY-101", Node: "implement", Agent: "build", Title: "PAY-101:implement", Prompt: "work", NudgePrompt: "custom instructions"}
216
217
  if err := a.EnsureNodeRuntime(ctx, nw, "/srv/payments", spec, NodeRuntime{NodeVisitID: "visit-2"}); err != nil {
@@ -259,8 +260,8 @@ func TestEnsureNodeRuntimeInitialLaunchAppendsCustomInstructions(t *testing.T) {
259
260
  }
260
261
  fh := &runtimeTestHarness{}
261
262
  fr := &runtimeTestRunner{}
262
- a := &Activities{Runner: fr, Harness: fh, Runs: p}
263
- nw := run.NodeWork{Work: run.Work{RunID: id}, Node: "implement", NodeVisitID: "visit-first"}
263
+ a := &Activities{Repos: runtimeTestRepos(&runtimeTestTaskSystem{}), Runner: fr, Harness: fh, Runs: p}
264
+ nw := run.NodeWork{Work: run.Work{RunID: id, Repo: "payments"}, Node: "implement", NodeVisitID: "visit-first"}
264
265
  spec := harness.LaunchSpec{RunID: id, NodeVisitID: "visit-first", Node: "implement", NodeType: workflow.NodeHITL, Agent: "build", Prompt: "standard prompt", NudgePrompt: "custom instructions"}
265
266
  if err := a.EnsureNodeRuntime(ctx, nw, "", spec, NodeRuntime{}); err != nil {
266
267
  t.Fatal(err)
@@ -273,6 +274,63 @@ func TestEnsureNodeRuntimeInitialLaunchAppendsCustomInstructions(t *testing.T) {
273
274
  }
274
275
  }
275
276
 
277
+ // TestEnsureNodeRuntimeSuppliesTaskSystemAgentEnv asserts the repo's task
278
+ // system supplies the agent workspace environment at launch time, so the
279
+ // harness/runner command addresses the same workspace as relay-flow. Adapters
280
+ // without the optional capability supply nothing.
281
+ func TestEnsureNodeRuntimeSuppliesTaskSystemAgentEnv(t *testing.T) {
282
+ ctx := context.Background()
283
+ db := openProjectionDB(t, filepath.Join(t.TempDir(), "state.db"))
284
+ defer db.Close()
285
+ p := &RunProjection{DB: db}
286
+ if err := p.migrate(); err != nil {
287
+ t.Fatal(err)
288
+ }
289
+ id := run.ID("payments/basic/PAY-105")
290
+ if err := p.insertStart(ctx, run.Start{ID: id, Repo: "payments", Workflow: workflow.Workflow{Name: "basic"}, Ticket: task.TicketRef{ID: "5", Key: "PAY-105"}}, time.Now().UTC()); err != nil {
291
+ t.Fatal(err)
292
+ }
293
+ if err := p.updateNodeRuntimeVisit(ctx, id, "implement", "visit-first"); err != nil {
294
+ t.Fatal(err)
295
+ }
296
+ want := map[string]string{"BEADS_DIR": "/var/lib/beads/payments/.beads", "BEADS_DB": "", "BD_DB": ""}
297
+ fh := &runtimeTestHarness{}
298
+ a := &Activities{
299
+ Repos: runtimeTestRepos(&runtimeTestTaskSystem{agentEnv: want}),
300
+ Runner: &runtimeTestRunner{},
301
+ Harness: fh,
302
+ Runs: p,
303
+ }
304
+ nw := run.NodeWork{Work: run.Work{RunID: id, Repo: "payments", Workflow: "basic", Parent: task.TicketRef{Key: "PAY-105"}}, Node: "implement", NodeVisitID: "visit-first"}
305
+ spec := harness.LaunchSpec{RunID: id, NodeVisitID: "visit-first", RepoName: "payments", Node: "implement", Agent: "build", Title: "PAY-105:implement"}
306
+ if err := a.EnsureNodeRuntime(ctx, nw, "/srv/payments", spec, NodeRuntime{}); err != nil {
307
+ t.Fatal(err)
308
+ }
309
+ if len(fh.taskEnvs) != 1 || !reflect.DeepEqual(fh.taskEnvs[0], want) {
310
+ t.Fatalf("launch TaskEnv = %#v, want [%#v]", fh.taskEnvs, want)
311
+ }
312
+
313
+ // An adapter without the optional capability supplies no environment.
314
+ plain := &runtimeTestHarness{}
315
+ b := &Activities{
316
+ Repos: runtimeTestRepos(&runtimeTestPlainTaskSystem{System: &runtimeTestTaskSystem{}}),
317
+ Runner: &runtimeTestRunner{},
318
+ Harness: plain,
319
+ Runs: p,
320
+ }
321
+ if err := p.updateNodeRuntimeVisit(ctx, id, "review", "visit-review"); err != nil {
322
+ t.Fatal(err)
323
+ }
324
+ nw.Node, nw.NodeVisitID = "review", "visit-review"
325
+ spec.Node, spec.NodeVisitID, spec.Title = "review", "visit-review", "PAY-105:review"
326
+ if err := b.EnsureNodeRuntime(ctx, nw, "/srv/payments", spec, NodeRuntime{}); err != nil {
327
+ t.Fatal(err)
328
+ }
329
+ if len(plain.taskEnvs) != 1 || plain.taskEnvs[0] != nil {
330
+ t.Fatalf("TaskEnv without a configured workspace = %#v, want [nil]", plain.taskEnvs)
331
+ }
332
+ }
333
+
276
334
  func TestEnsureNodeRuntimeSendFailureClosesLiveTerminal(t *testing.T) {
277
335
  ctx := context.Background()
278
336
  db := openProjectionDB(t, filepath.Join(t.TempDir(), "state.db"))
@@ -293,7 +351,7 @@ func TestEnsureNodeRuntimeSendFailureClosesLiveTerminal(t *testing.T) {
293
351
  }
294
352
  fr := &runtimeTestRunner{live: true, sendErr: errors.New("send failed")}
295
353
  fh := &runtimeTestHarness{}
296
- a := &Activities{Runner: fr, Harness: fh, Runs: p}
354
+ a := &Activities{Repos: runtimeTestRepos(&runtimeTestTaskSystem{}), Runner: fr, Harness: fh, Runs: p}
297
355
  nw := run.NodeWork{Work: run.Work{RunID: id, Repo: "payments", Workflow: "basic", Parent: task.TicketRef{Key: "PAY-102"}}, Node: "implement", NodeVisitID: "visit-new", Mailbox: task.Mailbox{Key: "PAY-234", Node: "implement"}}
298
356
  spec := harness.LaunchSpec{RunID: id, NodeVisitID: "visit-new", Node: "implement", Agent: "build", Title: "PAY-102:implement", Prompt: "work", NudgePrompt: "Read the latest review feedback."}
299
357
  if err := a.EnsureNodeRuntime(ctx, nw, "/srv/payments", spec, NodeRuntime{RunID: id, Node: "implement", TerminalID: "live-old", SessionID: "session-old", NodeVisitID: "visit-old"}); err != nil {
@@ -499,6 +557,7 @@ type runtimeTestHarness struct {
499
557
  prompts []string
500
558
  resumeIDs []string
501
559
  rendered []harness.PromptKind
560
+ taskEnvs []map[string]string
502
561
  }
503
562
 
504
563
  func (h *runtimeTestHarness) RenderPrompt(kind harness.PromptKind, _ harness.PromptData, nudge string) (string, error) {
@@ -522,9 +581,59 @@ func (h *runtimeTestHarness) BuildCommand(spec harness.LaunchSpec) (runner.Comma
522
581
  h.buildCalls++
523
582
  h.prompts = append(h.prompts, spec.Prompt)
524
583
  h.resumeIDs = append(h.resumeIDs, spec.ResumeID)
584
+ h.taskEnvs = append(h.taskEnvs, spec.TaskEnv)
525
585
  return runner.Command{Executable: "opencode", Args: []string{spec.ResumeID}}, nil
526
586
  }
527
587
 
588
+ // runtimeTestTaskSystem is a repo-bound task system stub. Only the optional
589
+ // task.AgentEnvironment capability is exercised here; every other method is an
590
+ // unused stub for the node runtime tests.
591
+ type runtimeTestTaskSystem struct {
592
+ agentEnv map[string]string
593
+ }
594
+
595
+ func (s *runtimeTestTaskSystem) AgentEnv() map[string]string { return s.agentEnv }
596
+
597
+ func (*runtimeTestTaskSystem) Poll(context.Context) ([]task.Ticket, error) { return nil, nil }
598
+ func (*runtimeTestTaskSystem) CompileFilter(config.RawValues) (func(task.Ticket) bool, error) {
599
+ return func(task.Ticket) bool { return true }, nil
600
+ }
601
+ func (*runtimeTestTaskSystem) Claim(context.Context, task.TicketRef, string) error { return nil }
602
+ func (*runtimeTestTaskSystem) ValidateConfig(context.Context, config.RawValues, map[string]config.RawValues) error {
603
+ return nil
604
+ }
605
+ func (*runtimeTestTaskSystem) RenderText(task.TextKind, task.TextData) (string, error) {
606
+ return "", nil
607
+ }
608
+ func (*runtimeTestTaskSystem) EnsureMailboxes(context.Context, task.TicketRef, string, []task.MailboxSpec) (map[string]task.Mailbox, error) {
609
+ return map[string]task.Mailbox{}, nil
610
+ }
611
+ func (*runtimeTestTaskSystem) ApplyTaskConfig(context.Context, task.Target, config.RawValues) error {
612
+ return nil
613
+ }
614
+ func (*runtimeTestTaskSystem) CompleteMailbox(context.Context, task.Mailbox) error { return nil }
615
+ func (*runtimeTestTaskSystem) HasComment(context.Context, task.Target, string) (bool, error) {
616
+ return false, nil
617
+ }
618
+ func (*runtimeTestTaskSystem) Comment(context.Context, task.Target, string, string) error {
619
+ return nil
620
+ }
621
+ func (*runtimeTestTaskSystem) ResetForRecovery(context.Context, task.TicketRef, []task.Mailbox, config.RawValues) error {
622
+ return nil
623
+ }
624
+
625
+ // runtimeTestPlainTaskSystem forwards the task.System contract without
626
+ // exposing the optional AgentEnvironment capability.
627
+ type runtimeTestPlainTaskSystem struct {
628
+ task.System
629
+ }
630
+
631
+ func runtimeTestRepos(sys task.System) *repo.Registry {
632
+ reg := &repo.Registry{}
633
+ reg.Replace(&repo.Repo{Name: "payments", Path: "/srv/payments", TaskSystem: sys})
634
+ return reg
635
+ }
636
+
528
637
  func openProjectionDB(t *testing.T, path string) *sql.DB {
529
638
  t.Helper()
530
639
  db, err := sql.Open("sqlite", path)
@@ -40,6 +40,21 @@ func (a *Activities) taskSystem(repoName string) (task.System, error) {
40
40
  return rp.TaskSystem, nil
41
41
  }
42
42
 
43
+ // agentEnv returns the repo task system's agent workspace environment, or nil
44
+ // when the adapter exposes none. It is resolved at launch time so no workspace
45
+ // value is carried in durable workflow history.
46
+ func (a *Activities) agentEnv(repoName string) (map[string]string, error) {
47
+ sys, err := a.taskSystem(repoName)
48
+ if err != nil {
49
+ return nil, err
50
+ }
51
+ provider, ok := sys.(task.AgentEnvironment)
52
+ if !ok {
53
+ return nil, nil
54
+ }
55
+ return provider.AgentEnv(), nil
56
+ }
57
+
43
58
  func (a *Activities) runSpec(w run.Work) runner.RunSpec {
44
59
  return runner.RunSpec{
45
60
  RunID: w.RunID,
@@ -228,6 +243,10 @@ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, rep
228
243
  if err != nil {
229
244
  return NodeRuntime{}, err
230
245
  }
246
+ spec.TaskEnv, err = a.agentEnv(nw.Repo)
247
+ if err != nil {
248
+ return NodeRuntime{}, err
249
+ }
231
250
  cmd, err := a.Harness.BuildCommand(spec)
232
251
  if err != nil {
233
252
  return NodeRuntime{}, err
@@ -56,6 +56,11 @@ type LaunchSpec struct {
56
56
  PromptData PromptData
57
57
  NextSteps []workflow.Route
58
58
  ResumeID string
59
+ // TaskEnv is the task-system-owned workspace environment for the agent
60
+ // process, supplied by the repo's task adapter. The harness adds it to the
61
+ // launch command environment without interpreting it; relay-flow's own
62
+ // variables always win on conflict.
63
+ TaskEnv map[string]string
59
64
  }
60
65
 
61
66
  type Harness interface {
@@ -3,7 +3,8 @@
3
3
  // their stable <ticket>:<node> title, and builds the structured command
4
4
  // the runner executes. The OpenCode runtime plugin (TypeScript, under
5
5
  // plugin/) owns message parsing, title pinning, nudge-on-invalid for
6
- // agent nodes, silence for HITL, and exact-report JSON retry with the
6
+ // agent nodes and for non-empty invalid HITL output, silence for missing
7
+ // or aborted HITL output, and exact-report JSON retry with the
7
8
  // shared backoff constants mirrored in TypeScript.
8
9
  package opencode
9
10
 
@@ -186,7 +187,13 @@ func (h *Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error)
186
187
  if err != nil {
187
188
  return runner.Command{}, err
188
189
  }
189
- env := map[string]string{
190
+ env := map[string]string{}
191
+ // Task-system workspace values are applied first so relay-flow's own
192
+ // variables always win on conflict.
193
+ for key, value := range spec.TaskEnv {
194
+ env[key] = value
195
+ }
196
+ for key, value := range map[string]string{
190
197
  "RELAY_FLOW_HOME": root,
191
198
  "RELAY_FLOW_RUN_ID": string(spec.RunID),
192
199
  "RELAY_FLOW_WORKFLOW": spec.Workflow,
@@ -196,6 +203,8 @@ func (h *Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error)
196
203
  "RELAY_FLOW_NODE_TYPE": string(spec.NodeType),
197
204
  "RELAY_FLOW_NUDGE_PROMPT": spec.NudgePrompt,
198
205
  "RELAY_FLOW_NEXT_STEPS_JSON": string(nextSteps),
206
+ } {
207
+ env[key] = value
199
208
  }
200
209
  args := []string{}
201
210
  if spec.ResumeID != "" {
@@ -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.2.5-alpha"
17
+ const configuredPlugin = "relay-flow-plugin@0.2.6-alpha"
18
18
 
19
19
  func TestBuildCommandArgv(t *testing.T) {
20
20
  t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
@@ -10,7 +10,7 @@ import (
10
10
  "github.com/rajpopat27/relay-flow/internal/config"
11
11
  )
12
12
 
13
- const relayFlowPlugin = "relay-flow-plugin@0.2.5-alpha"
13
+ const relayFlowPlugin = "relay-flow-plugin@0.2.6-alpha"
14
14
 
15
15
  type jsoncToken struct {
16
16
  kind byte
@@ -0,0 +1,57 @@
1
+ package opencode_test
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/rajpopat27/relay-flow/internal/harness"
7
+ "github.com/rajpopat27/relay-flow/internal/harness/opencode"
8
+ )
9
+
10
+ // TestBuildCommandCarriesTaskSystemEnvironment asserts the task-system
11
+ // workspace environment reaches the runner command so agent task commands
12
+ // address the same workspace as relay-flow, and that relay-flow's own
13
+ // variables cannot be overwritten by the task system.
14
+ func TestBuildCommandCarriesTaskSystemEnvironment(t *testing.T) {
15
+ t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
16
+
17
+ cmd, err := opencode.New().BuildCommand(harness.LaunchSpec{
18
+ Agent: "build",
19
+ Node: "implement",
20
+ Prompt: "implement the ticket",
21
+ TaskEnv: map[string]string{
22
+ "BEADS_DIR": "/var/lib/beads/payments/.beads",
23
+ "BEADS_DB": "",
24
+ "BD_DB": "",
25
+ "RELAY_FLOW_NODE": "hijacked",
26
+ },
27
+ })
28
+ if err != nil {
29
+ t.Fatalf("BuildCommand: %v", err)
30
+ }
31
+ if cmd.Env["BEADS_DIR"] != "/var/lib/beads/payments/.beads" {
32
+ t.Fatalf("BEADS_DIR = %q", cmd.Env["BEADS_DIR"])
33
+ }
34
+ for _, key := range []string{"BEADS_DB", "BD_DB"} {
35
+ value, ok := cmd.Env[key]
36
+ if !ok || value != "" {
37
+ t.Fatalf("%s = %q, present=%v; want empty selector", key, value, ok)
38
+ }
39
+ }
40
+ if cmd.Env["RELAY_FLOW_NODE"] != "implement" {
41
+ t.Fatalf("RELAY_FLOW_NODE = %q, want implement", cmd.Env["RELAY_FLOW_NODE"])
42
+ }
43
+ }
44
+
45
+ // TestBuildCommandWithoutTaskEnvKeepsRelayVariablesOnly asserts an adapter
46
+ // that exposes no workspace environment adds nothing to the command.
47
+ func TestBuildCommandWithoutTaskEnvKeepsRelayVariablesOnly(t *testing.T) {
48
+ t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
49
+
50
+ cmd, err := opencode.New().BuildCommand(harness.LaunchSpec{Agent: "build", Prompt: "work"})
51
+ if err != nil {
52
+ t.Fatalf("BuildCommand: %v", err)
53
+ }
54
+ if len(cmd.Env) != 9 {
55
+ t.Fatalf("Env = %#v, want only the nine relay-flow variables", cmd.Env)
56
+ }
57
+ }
@@ -189,7 +189,13 @@ func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
189
189
  if err != nil {
190
190
  return runner.Command{}, err
191
191
  }
192
- 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{
193
199
  "RELAY_FLOW_HOME": root,
194
200
  "RELAY_FLOW_RUN_ID": string(spec.RunID),
195
201
  "RELAY_FLOW_WORKFLOW": spec.Workflow,
@@ -199,6 +205,8 @@ func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
199
205
  "RELAY_FLOW_NODE_TYPE": string(spec.NodeType),
200
206
  "RELAY_FLOW_NUDGE_PROMPT": spec.NudgePrompt,
201
207
  "RELAY_FLOW_NEXT_STEPS_JSON": string(nextSteps),
208
+ } {
209
+ env[key] = value
202
210
  }
203
211
  args := []string{"--name", spec.Title}
204
212
  if spec.Agent != "default" {
@@ -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
+ }
@@ -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.
@@ -0,0 +1,52 @@
1
+ package beads
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "path/filepath"
7
+ "reflect"
8
+ "testing"
9
+
10
+ "github.com/rajpopat27/relay-flow/internal/config"
11
+ "github.com/rajpopat27/relay-flow/internal/task"
12
+ )
13
+
14
+ // TestAgentEnvSelectsRegisteredWorkspace asserts that a repo-bound Beads
15
+ // system exposes the configured workspace to agent processes and neutralizes
16
+ // ambient Beads selectors, mirroring the adapter's own child-command
17
+ // isolation.
18
+ func TestAgentEnvSelectsRegisteredWorkspace(t *testing.T) {
19
+ installRepoCompositionFakeBD(t)
20
+ root := t.TempDir()
21
+ codePath := filepath.Join(root, "code", "payments")
22
+ beadsDir := filepath.Join(root, "beads", "payments", ".beads")
23
+ for _, path := range []string{codePath, beadsDir} {
24
+ if err := os.MkdirAll(path, 0o755); err != nil {
25
+ t.Fatal(err)
26
+ }
27
+ }
28
+
29
+ sys, err := newSystem(context.Background(), task.RepoSpec{
30
+ Name: "payments", Path: codePath,
31
+ RepoConfig: config.RawValues{"beadsDir": beadsDir},
32
+ })
33
+ if err != nil {
34
+ t.Fatal(err)
35
+ }
36
+ provider, ok := sys.(task.AgentEnvironment)
37
+ if !ok {
38
+ t.Fatal("Beads system does not expose task.AgentEnvironment")
39
+ }
40
+ canonical, err := canonicalBeadsDir(beadsDir)
41
+ if err != nil {
42
+ t.Fatal(err)
43
+ }
44
+ want := map[string]string{
45
+ "BEADS_DIR": canonical,
46
+ "BEADS_DB": "",
47
+ "BD_DB": "",
48
+ }
49
+ if got := provider.AgentEnv(); !reflect.DeepEqual(got, want) {
50
+ t.Fatalf("AgentEnv() = %#v, want %#v", got, want)
51
+ }
52
+ }
@@ -83,8 +83,19 @@ func DefaultConfig() config.RawValues {
83
83
 
84
84
  func init() {
85
85
  task.Register("beads", task.Factory{
86
- RequiredRepoKeys: func() []string { return []string{"beadsDir"} },
87
- TaskScopeKey: beadsTaskScopeKey,
86
+ RequiredRepoKeys: func() []string { return []string{"beadsDir"} },
87
+ TaskScopeKey: beadsTaskScopeKey,
88
+ RegistrationKey: func(spec task.RepoRegistrationSpec) (string, error) {
89
+ label, err := RepositoryLabel(spec.Name)
90
+ if err != nil {
91
+ return "", err
92
+ }
93
+ scope, err := beadsTaskScopeKey(spec.RootConfig, spec.RepoConfig)
94
+ if err != nil {
95
+ return "", err
96
+ }
97
+ return scope + "\x00" + label, nil
98
+ },
88
99
  Auth: beadsAuth,
89
100
  DefaultConfig: DefaultConfig,
90
101
  ValidateTextConfig: validateTextConfig,
@@ -98,16 +109,70 @@ const (
98
109
  statusOpen = "open"
99
110
  statusInProgress = "in_progress"
100
111
  statusClosed = "closed"
112
+
113
+ repositoryLabelPrefix = "repo:"
114
+ maxRepositoryLabelLen = 255
101
115
  )
102
116
 
117
+ var repositoryNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
118
+
119
+ // RepositoryLabel derives the reserved Beads label used to isolate one
120
+ // registered code repository from other repositories sharing a workspace.
121
+ // Repository names are normalized to lower case so labels remain stable and
122
+ // two names that differ only by case cannot claim the same Beads issues.
123
+ func RepositoryLabel(name string) (string, error) {
124
+ if name == "" || strings.TrimSpace(name) != name {
125
+ return "", errors.New("repository name must be non-empty and must not have surrounding whitespace")
126
+ }
127
+ if !repositoryNamePattern.MatchString(name) {
128
+ return "", fmt.Errorf("repository name %q cannot derive a Beads label; use only letters, numbers, '.', '_' or '-'", name)
129
+ }
130
+ label := repositoryLabelPrefix + strings.ToLower(name)
131
+ if len(label) > maxRepositoryLabelLen {
132
+ return "", fmt.Errorf("repository name %q derives Beads label of %d characters; maximum is %d", name, len(label), maxRepositoryLabelLen)
133
+ }
134
+ return label, nil
135
+ }
136
+
137
+ func hasRepositoryLabel(labels []string, want string) bool {
138
+ if want == "" {
139
+ return false
140
+ }
141
+ found := ""
142
+ count := 0
143
+ for _, label := range labels {
144
+ if !strings.HasPrefix(label, repositoryLabelPrefix) {
145
+ continue
146
+ }
147
+ count++
148
+ found = label
149
+ }
150
+ return count == 1 && found == want
151
+ }
152
+
103
153
  // system is one repo-bound Beads task system. Its CLI client serializes
104
154
  // commands for the selected workspace, making the system safe for concurrent
105
155
  // Repo Poller and durable-activity use.
106
156
  type system struct {
107
- cli bdcli.Client
108
- mailboxMu sync.Mutex
109
- base config.RawValues
110
- effective Config
157
+ cli bdcli.Client
158
+ repositoryLabel string
159
+ mailboxMu sync.Mutex
160
+ base config.RawValues
161
+ effective Config
162
+ beadsDir string
163
+ }
164
+
165
+ // AgentEnv returns the Beads selector environment an agent process needs to
166
+ // address the registered workspace. The configured workspace is the sole
167
+ // BEADS_DIR value; the other selectors are emitted empty so an ambient
168
+ // BEADS_DB or BD_DB cannot redirect the agent's bd commands. This mirrors the
169
+ // adapter's own child-process isolation in bdcli.
170
+ func (s *system) AgentEnv() map[string]string {
171
+ return map[string]string{
172
+ "BEADS_DIR": s.beadsDir,
173
+ "BEADS_DB": "",
174
+ "BD_DB": "",
175
+ }
111
176
  }
112
177
 
113
178
  // beadsTaskScopeKey returns the canonical physical Beads workspace. The
@@ -157,6 +222,10 @@ func newSystem(ctx context.Context, spec task.RepoSpec) (task.System, error) {
157
222
  if strings.TrimSpace(spec.Name) == "" {
158
223
  return nil, errors.New("beads: repo name is required")
159
224
  }
225
+ repositoryLabel, err := RepositoryLabel(spec.Name)
226
+ if err != nil {
227
+ return nil, fmt.Errorf("beads repo %q: %w", spec.Name, err)
228
+ }
160
229
  // Validate the repo-scoped key before merging with root values. This keeps a
161
230
  // root beadsDir from silently satisfying repository registration.
162
231
  beadsDir, err := beadsTaskScopeKey(spec.RootConfig, spec.RepoConfig)
@@ -175,7 +244,13 @@ func newSystem(ctx context.Context, spec task.RepoSpec) (task.System, error) {
175
244
  if err := cli.Probe(ctx); err != nil {
176
245
  return nil, fmt.Errorf("beads repo %q probe: %w", spec.Name, err)
177
246
  }
178
- return &system{cli: cli, base: merged, effective: cfg}, nil
247
+ return &system{
248
+ cli: cli,
249
+ repositoryLabel: repositoryLabel,
250
+ base: merged,
251
+ effective: cfg,
252
+ beadsDir: beadsDir,
253
+ }, nil
179
254
  }
180
255
 
181
256
  func decodeConfig(raw config.RawValues) (Config, error) {
@@ -275,6 +350,11 @@ func (s *system) Poll(ctx context.Context) ([]task.Ticket, error) {
275
350
  if strings.TrimSpace(issue.Parent) != "" {
276
351
  continue
277
352
  }
353
+ // Missing and ambiguous repository labels are deliberately ignored before
354
+ // the normalized ticket reaches workflow matching or routing.
355
+ if !hasRepositoryLabel(issue.Labels, s.repositoryLabel) {
356
+ continue
357
+ }
278
358
  tickets = append(tickets, issueToTicket(issue))
279
359
  }
280
360
  return tickets, nil
@@ -194,17 +194,86 @@ func TestExtractWorkflowClaimsKeepsOnlyWorkflowLabels(t *testing.T) {
194
194
  }
195
195
  }
196
196
 
197
+ func TestRepositoryLabelIsStableAndValidated(t *testing.T) {
198
+ got, err := RepositoryLabel("Payments_API")
199
+ if err != nil {
200
+ t.Fatal(err)
201
+ }
202
+ if got != "repo:payments_api" {
203
+ t.Fatalf("RepositoryLabel = %q, want repo:payments_api", got)
204
+ }
205
+ for _, name := range []string{"", " payments", "payments/api", "payments api"} {
206
+ if _, err := RepositoryLabel(name); err == nil {
207
+ t.Fatalf("RepositoryLabel accepted invalid name %q", name)
208
+ }
209
+ }
210
+ }
211
+
212
+ func TestRepositoryLabelEnforcesMaximumLength(t *testing.T) {
213
+ validName := strings.Repeat("a", 250)
214
+ label, err := RepositoryLabel(validName)
215
+ if err != nil {
216
+ t.Fatalf("maximum-length repository name rejected: %v", err)
217
+ }
218
+ if len(label) != maxRepositoryLabelLen {
219
+ t.Fatalf("maximum-length label has length %d, want %d", len(label), maxRepositoryLabelLen)
220
+ }
221
+
222
+ tooLong := strings.Repeat("a", 251)
223
+ if _, err := RepositoryLabel(tooLong); err == nil || !strings.Contains(err.Error(), "maximum is 255") {
224
+ t.Fatalf("overlong repository name error = %v, want the 255-character validation", err)
225
+ }
226
+ }
227
+
228
+ func TestRegistrationKeyAndNewSystemRejectOverlongRepositoryName(t *testing.T) {
229
+ name := strings.Repeat("a", 251)
230
+ workspace := t.TempDir()
231
+ if _, err := task.RegistrationKey("beads", task.RepoRegistrationSpec{
232
+ Name: name,
233
+ RepoConfig: config.RawValues{"beadsDir": workspace},
234
+ }); err == nil {
235
+ t.Fatal("registration identity accepted an overlong repository name")
236
+ }
237
+
238
+ _, err := newSystem(context.Background(), task.RepoSpec{
239
+ Name: name,
240
+ Path: t.TempDir(),
241
+ RepoConfig: config.RawValues{"beadsDir": filepath.Join(workspace, "missing")},
242
+ })
243
+ if err == nil || !strings.Contains(err.Error(), "maximum is 255") {
244
+ t.Fatalf("newSystem overlong-name error = %v, want the label validation before workspace probing", err)
245
+ }
246
+ }
247
+
248
+ func TestPollFiltersMissingOtherAndAmbiguousRepositoryLabels(t *testing.T) {
249
+ fake := &pollClient{ready: []bdcli.Issue{
250
+ {ID: "payments-1", Title: "payments", Labels: []string{"repo:payments", "wf:implementation"}},
251
+ {ID: "unowned-1", Title: "unowned", Labels: []string{"wf:implementation"}},
252
+ {ID: "platform-1", Title: "platform", Labels: []string{"repo:platform", "wf:implementation"}},
253
+ {ID: "ambiguous-1", Title: "ambiguous", Labels: []string{"repo:payments", "repo:platform", "wf:implementation"}},
254
+ {ID: "duplicate-1", Title: "duplicate", Labels: []string{"repo:payments", "repo:payments"}},
255
+ }}
256
+ got, err := (&system{cli: fake, repositoryLabel: "repo:payments"}).Poll(context.Background())
257
+ if err != nil {
258
+ t.Fatal(err)
259
+ }
260
+ if len(got) != 1 || got[0].ID != "payments-1" {
261
+ t.Fatalf("Poll = %+v, want only the payments-labelled parent", got)
262
+ }
263
+ if got[0].WorkflowClaims[0] != "wf:implementation" {
264
+ t.Fatalf("workflow labels were not retained after repository filtering: %+v", got[0])
265
+ }
266
+ }
267
+
197
268
  func TestPollDeduplicatesReadyAndClaimedParentsByIssueID(t *testing.T) {
198
- ready := bdcli.Issue{ID: "demo-a1b2", Title: "ready copy", Status: "open", IssueType: "epic"}
199
- claimedDuplicate := bdcli.Issue{ID: "demo-a1b2", Title: "claimed copy", Status: "in_progress", IssueType: "epic", Labels: []string{"wf:implementation"}}
200
- claimedOnly := bdcli.Issue{ID: "demo-c3d4", Title: "claimed only", Status: "blocked", IssueType: "task", Labels: []string{"wf:review"}}
269
+ ready := bdcli.Issue{ID: "demo-a1b2", Title: "ready copy", Status: "open", IssueType: "epic", Labels: []string{"repo:payments"}}
270
+ claimedDuplicate := bdcli.Issue{ID: "demo-a1b2", Title: "claimed copy", Status: "in_progress", IssueType: "epic", Labels: []string{"repo:payments", "wf:implementation"}}
271
+ claimedOnly := bdcli.Issue{ID: "demo-c3d4", Title: "claimed only", Status: "blocked", IssueType: "task", Labels: []string{"repo:payments", "wf:review"}}
201
272
  fake := &pollClient{
202
273
  ready: []bdcli.Issue{ready},
203
274
  claimed: []bdcli.Issue{claimedDuplicate, claimedOnly},
204
275
  }
205
- sys := &system{cli: fake}
206
-
207
- got, err := sys.Poll(context.Background())
276
+ got, err := (&system{cli: fake, repositoryLabel: "repo:payments"}).Poll(context.Background())
208
277
  if err != nil {
209
278
  t.Fatal(err)
210
279
  }
@@ -230,10 +299,10 @@ func TestPollDeduplicatesReadyAndClaimedParentsByIssueID(t *testing.T) {
230
299
  }
231
300
 
232
301
  func TestNormalizedChildIsExcludedFromPolling(t *testing.T) {
233
- parent := bdcli.Issue{ID: "demo-parent", Title: "parent", Status: "open", IssueType: "epic"}
234
- child := bdcli.Issue{ID: "demo-parent.1", Title: "demo-parent:implement", Status: "open", IssueType: "task", Parent: "demo-parent"}
302
+ parent := bdcli.Issue{ID: "demo-parent", Title: "parent", Status: "open", IssueType: "epic", Labels: []string{"repo:payments"}}
303
+ child := bdcli.Issue{ID: "demo-parent.1", Title: "demo-parent:implement", Status: "open", IssueType: "task", Parent: "demo-parent", Labels: []string{"repo:payments"}}
235
304
  fake := &pollClient{ready: []bdcli.Issue{child, parent}, claimed: []bdcli.Issue{child}}
236
- got, err := (&system{cli: fake}).Poll(context.Background())
305
+ got, err := (&system{cli: fake, repositoryLabel: "repo:payments"}).Poll(context.Background())
237
306
  if err != nil {
238
307
  t.Fatal(err)
239
308
  }
@@ -99,7 +99,7 @@ func TestBeadsRepoRegistrationUsesIndependentSystemsAndPollers(t *testing.T) {
99
99
  assertBDInvocationReachedRepoAndWorkspace(t, logPath, platformPath, platformBeads)
100
100
  }
101
101
 
102
- func TestBeadsRepoRegistrationRejectsSharedCanonicalWorkspace(t *testing.T) {
102
+ func TestBeadsRepoRegistrationAllowsSharedCanonicalWorkspaceWithDistinctLabels(t *testing.T) {
103
103
  root := t.TempDir()
104
104
  installRepoCompositionFakeBD(t)
105
105
  configPath := filepath.Join(root, "config.yaml")
@@ -135,11 +135,55 @@ func TestBeadsRepoRegistrationRejectsSharedCanonicalWorkspace(t *testing.T) {
135
135
  if _, err := svc.Register(context.Background(), repo.RegisterInput{
136
136
  Name: "platform", Path: secondPath,
137
137
  TaskConfig: config.RawValues{"beadsDir": filepath.Join(workspace, ".")},
138
+ }); err != nil {
139
+ t.Fatalf("second repo sharing canonical beadsDir with a distinct label was rejected: %v", err)
140
+ }
141
+ if len(svc.Registry().List()) != 2 {
142
+ t.Fatalf("registry after shared workspace registration = %d, want 2", len(svc.Registry().List()))
143
+ }
144
+ }
145
+
146
+ func TestBeadsRepoRegistrationRejectsRepositoryLabelCollision(t *testing.T) {
147
+ root := t.TempDir()
148
+ installRepoCompositionFakeBD(t)
149
+ configPath := filepath.Join(root, "config.yaml")
150
+ if err := config.SaveMachine(configPath, &config.Machine{
151
+ TaskPlugin: "beads", RunnerPlugin: "orca", HarnessPlugin: "opencode",
152
+ Repos: map[string]config.Repo{},
153
+ }); err != nil {
154
+ t.Fatal(err)
155
+ }
156
+ firstPath := filepath.Join(root, "code", "payments")
157
+ secondPath := filepath.Join(root, "code", "PAYMENTS")
158
+ workspace := filepath.Join(root, "beads", "shared", ".beads")
159
+ for _, path := range []string{firstPath, secondPath, workspace} {
160
+ if err := os.MkdirAll(path, 0o755); err != nil {
161
+ t.Fatal(err)
162
+ }
163
+ }
164
+
165
+ svc := repo.NewService(repo.ServiceConfig{
166
+ ConfigPath: configPath,
167
+ TaskPlugin: "beads",
168
+ Runner: &compositionRunner{},
169
+ Harness: &compositionHarness{},
170
+ Active: &compositionActive{},
171
+ Workflows: &compositionWorkflowRefs{},
172
+ })
173
+ if _, err := svc.Register(context.Background(), repo.RegisterInput{
174
+ Name: "payments", Path: firstPath,
175
+ TaskConfig: config.RawValues{"beadsDir": workspace},
176
+ }); err != nil {
177
+ t.Fatal(err)
178
+ }
179
+ if _, err := svc.Register(context.Background(), repo.RegisterInput{
180
+ Name: "PAYMENTS", Path: secondPath,
181
+ TaskConfig: config.RawValues{"beadsDir": workspace},
138
182
  }); err == nil {
139
- t.Fatal("second repo sharing canonical beadsDir was accepted")
183
+ t.Fatal("repository label collision was accepted")
140
184
  }
141
185
  if len(svc.Registry().List()) != 1 {
142
- t.Fatalf("registry after duplicate scope rejection = %d, want 1", len(svc.Registry().List()))
186
+ t.Fatalf("registry after label collision = %d, want 1", len(svc.Registry().List()))
143
187
  }
144
188
  }
145
189
 
@@ -20,13 +20,25 @@ type RepoSpec struct {
20
20
  RepoConfig config.RawValues
21
21
  }
22
22
 
23
+ // RepoRegistrationSpec carries the values a task plugin may need to derive a
24
+ // registration identity. Unlike RepoSpec, it does not include the code path
25
+ // or construct a task System.
26
+ type RepoRegistrationSpec struct {
27
+ Name string
28
+ RootConfig config.RawValues
29
+ RepoConfig config.RawValues
30
+ }
31
+
23
32
  // Factory constructs a task System. RequiredRepoKeys returns the explicit
24
33
  // repo YAML keys needed at registration. TaskScopeKey derives an opaque
25
34
  // canonical physical task scope (such as Jira site/project/component) used
26
- // to reject duplicate scope registration.
35
+ // to reject duplicate scope registration. RegistrationKey optionally derives
36
+ // the complete registration identity when a plugin permits sharing a
37
+ // physical scope with distinct logical repositories.
27
38
  type Factory struct {
28
39
  RequiredRepoKeys func() []string
29
40
  TaskScopeKey func(rootConfig, repoConfig config.RawValues) (string, error)
41
+ RegistrationKey func(RepoRegistrationSpec) (string, error)
30
42
  Auth func(context.Context, []string, io.Reader) error
31
43
  DefaultConfig func() config.RawValues
32
44
  ValidateTextConfig func(config.RawValues) error
@@ -122,7 +134,10 @@ func RequiredRepoKeys(name string) ([]string, error) {
122
134
  return f.RequiredRepoKeys(), nil
123
135
  }
124
136
 
125
- // TaskScopeKey derives the canonical task scope for the named plugin.
137
+ // TaskScopeKey derives the canonical physical task scope for the named
138
+ // plugin. It is retained separately from RegistrationKey so callers that
139
+ // need provider workspace identity do not have to know about logical repo
140
+ // ownership.
126
141
  func TaskScopeKey(name string, rootConfig, repoConfig config.RawValues) (string, error) {
127
142
  f, err := lookup(name)
128
143
  if err != nil {
@@ -131,6 +146,20 @@ func TaskScopeKey(name string, rootConfig, repoConfig config.RawValues) (string,
131
146
  return f.TaskScopeKey(rootConfig, repoConfig)
132
147
  }
133
148
 
149
+ // RegistrationKey derives the identity used to reject conflicting repo
150
+ // registrations. Plugins that do not provide a custom key fall back to their
151
+ // physical TaskScopeKey, preserving the original duplicate-scope behavior.
152
+ func RegistrationKey(name string, spec RepoRegistrationSpec) (string, error) {
153
+ f, err := lookup(name)
154
+ if err != nil {
155
+ return "", err
156
+ }
157
+ if f.RegistrationKey != nil {
158
+ return f.RegistrationKey(spec)
159
+ }
160
+ return f.TaskScopeKey(spec.RootConfig, spec.RepoConfig)
161
+ }
162
+
134
163
  // ValidateName returns an error listing registered names when name is not
135
164
  // a registered plugin. Used by `relay-flow init` to reject unknown plugin
136
165
  // selections without constructing a System.
@@ -99,6 +99,16 @@ type System interface {
99
99
  ResetForRecovery(ctx context.Context, parent TicketRef, mailboxes []Mailbox, taskConfig config.RawValues) error
100
100
  }
101
101
 
102
+ // AgentEnvironment is an optional adapter capability. It returns the
103
+ // task-system workspace environment an agent process must receive so agent
104
+ // task commands address the same workspace as relay-flow. Values are
105
+ // workspace selectors only, never credentials; an empty value explicitly
106
+ // neutralizes an ambient selector. Core forwards the map through the harness
107
+ // launch spec without learning adapter vocabulary.
108
+ type AgentEnvironment interface {
109
+ AgentEnv() map[string]string
110
+ }
111
+
102
112
  // RestartPreparer is an optional adapter capability used only by explicit
103
113
  // run restarts. It reopens relay-owned mailbox state while preserving all
104
114
  // comments, labels, and descriptions. A human-owned incompatible state must
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-flow",
3
- "version": "0.2.5-alpha",
3
+ "version": "0.2.6-alpha",
4
4
  "description": "Graph-based agent workflow engine — tracker-agnostic, pluggable runners",
5
5
  "bin": {
6
6
  "relay-flow": "./bin/relay-flow.js",