relay-flow 0.2.4-alpha → 0.2.5-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 (45) hide show
  1. package/README.md +20 -15
  2. package/cmd/relay-flow/backend_selection_test.go +149 -0
  3. package/cmd/relay-flow/main.go +94 -13
  4. package/cmd/relay-flow/scenario_test.go +19 -2
  5. package/cmd/relay-flow/serve.go +98 -19
  6. package/cmd/relay-flow/serve_recovery_test.go +100 -0
  7. package/cmd/relay-flow/temporal_init.go +170 -0
  8. package/cmd/relay-flow/temporal_init_test.go +217 -0
  9. package/cmd/relay-flow/temporal_report_test.go +733 -0
  10. package/examples/config-reference.yaml +2 -2
  11. package/examples/minimal-beads-task-workflow.yaml +2 -1
  12. package/examples/workflow-reference.yaml +2 -1
  13. package/go.mod +37 -16
  14. package/go.sum +129 -61
  15. package/internal/config/machine.go +33 -1
  16. package/internal/config/machine_test.go +76 -0
  17. package/internal/execution/goworkflows/engine.go +13 -38
  18. package/internal/execution/goworkflows/projection.go +47 -464
  19. package/internal/execution/projection/projection.go +867 -0
  20. package/internal/execution/projection/projection_test.go +347 -0
  21. package/internal/execution/temporal/activities.go +567 -0
  22. package/internal/execution/temporal/engine.go +384 -0
  23. package/internal/execution/temporal/engine_test.go +277 -0
  24. package/internal/execution/temporal/interpreter.go +736 -0
  25. package/internal/execution/temporal/operations.go +455 -0
  26. package/internal/execution/temporal/operations_test.go +101 -0
  27. package/internal/execution/temporal/recovery.go +194 -0
  28. package/internal/execution/temporal/recovery_runtime.go +41 -0
  29. package/internal/execution/temporal/recovery_test.go +102 -0
  30. package/internal/execution/temporal/snapshot_restart_test.go +72 -0
  31. package/internal/execution/temporal/spike_test.go +934 -0
  32. package/internal/execution/temporal/visibility_lag_test.go +415 -0
  33. package/internal/harness/opencode/opencode.go +3 -1
  34. package/internal/harness/opencode/opencode_test.go +1 -1
  35. package/internal/harness/opencode/repo_setup.go +1 -1
  36. package/internal/harness/pi/pi.go +49 -46
  37. package/internal/harness/pi/pi_test.go +26 -10
  38. package/internal/harness/pi/prompt_test.go +30 -1
  39. package/internal/harness/pi/validation_test.go +27 -51
  40. package/internal/runner/herdr/herdr.go +14 -0
  41. package/internal/runner/herdr/herdr_test.go +20 -0
  42. package/internal/runner/orca/orca.go +33 -0
  43. package/internal/runner/orca/orca_test.go +33 -4
  44. package/internal/runner/runner.go +8 -0
  45. package/package.json +1 -1
@@ -0,0 +1,415 @@
1
+ package temporal
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "os"
7
+ "path/filepath"
8
+ "sync"
9
+ "testing"
10
+ "time"
11
+
12
+ "github.com/rajpopat27/relay-flow/internal/config"
13
+ "github.com/rajpopat27/relay-flow/internal/execution/projection"
14
+ "github.com/rajpopat27/relay-flow/internal/harness"
15
+ "github.com/rajpopat27/relay-flow/internal/identity"
16
+ "github.com/rajpopat27/relay-flow/internal/repo"
17
+ "github.com/rajpopat27/relay-flow/internal/retry"
18
+ "github.com/rajpopat27/relay-flow/internal/run"
19
+ "github.com/rajpopat27/relay-flow/internal/runner"
20
+ "github.com/rajpopat27/relay-flow/internal/task"
21
+ domainworkflow "github.com/rajpopat27/relay-flow/internal/workflow"
22
+ enumspb "go.temporal.io/api/enums/v1"
23
+ "go.temporal.io/sdk/client"
24
+ )
25
+
26
+ func TestTemporalRecoveryReconcilesActiveVisibilityLag(t *testing.T) {
27
+ if os.Getenv("RELAY_FLOW_TEMPORAL_LIVE") != "1" {
28
+ t.Skip("set RELAY_FLOW_TEMPORAL_LIVE=1 to run active Visibility-lag recovery against the local server")
29
+ }
30
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
31
+ defer cancel()
32
+ namespace := "relay-flow-visibility-lag-" + string(identity.NewNodeVisitID())[:12]
33
+ if err := ensureSpikeNamespace(ctx, "localhost:7233", namespace); err != nil {
34
+ t.Fatal(err)
35
+ }
36
+ root := t.TempDir()
37
+ database := filepath.Join(root, "state.db")
38
+ if err := projection.InitDatabaseWithIdentity(database, projection.ExecutorIdentity{
39
+ ExecutorPlugin: "temporal", TemporalAddress: "localhost:7233", TemporalNamespace: namespace,
40
+ }); err != nil {
41
+ t.Fatal(err)
42
+ }
43
+ sys := &lagTaskSystem{}
44
+ rnr := &lagRunner{}
45
+ hrn := &lagHarness{}
46
+ registry := repo.NewRegistry()
47
+ workflow := lagWorkflow()
48
+ registry.Replace(&repo.Repo{
49
+ Name: "repo", Path: "/repo", TaskSystem: sys,
50
+ Workflows: []repo.WorkflowBinding{{Workflow: &workflow}},
51
+ })
52
+ engine, err := New(database, Dependencies{
53
+ Repos: registry, Runner: rnr, Harness: hrn, TaskSystem: "lag-task",
54
+ TemporalAddress: "localhost:7233", TemporalNamespace: namespace,
55
+ Runtime: &run.RuntimePolicy{KeepTerminalsAlive: true, KeepSessionsAlive: true},
56
+ })
57
+ if err != nil {
58
+ t.Fatal(err)
59
+ }
60
+ if err := engine.Start(ctx); err != nil {
61
+ t.Fatal(err)
62
+ }
63
+ defer engine.Shutdown(context.Background())
64
+ start := run.Start{
65
+ ID: identity.NewRunID("repo", workflow.Name, "LAG-1"), Repo: "repo", RepoPath: "/repo",
66
+ Workflow: workflow, Ticket: task.TicketRef{ID: "LAG-1", Key: "LAG-1", Title: "Visibility lag"},
67
+ Runtime: run.RuntimePolicy{KeepTerminalsAlive: true, KeepSessionsAlive: true},
68
+ }
69
+ created, err := engine.EnsureRun(ctx, start)
70
+ if err != nil || !created {
71
+ t.Fatalf("EnsureRun = %v, %v", created, err)
72
+ }
73
+ waitForLagState(t, ctx, engine, start.ID, run.StateWaiting)
74
+ rnr.setLive(true)
75
+ description, err := engine.client.DescribeWorkflowExecution(ctx, string(start.ID), "")
76
+ if err != nil {
77
+ t.Fatal(err)
78
+ }
79
+ if description.WorkflowExecutionInfo == nil || description.WorkflowExecutionInfo.Execution == nil {
80
+ t.Fatal("Temporal describe returned no active execution")
81
+ }
82
+ runID := description.WorkflowExecutionInfo.Execution.RunId
83
+ beforeReconcile := countTemporalSignals(t, engine, start.ID, runID, reconcileSignalName)
84
+ created, err = engine.EnsureRun(ctx, start)
85
+ if err != nil || created {
86
+ t.Fatalf("duplicate EnsureRun = %v, %v", created, err)
87
+ }
88
+ if got := countTemporalSignals(t, engine, start.ID, runID, reconcileSignalName); got != beforeReconcile {
89
+ t.Fatalf("healthy terminal reconciliation emitted %d signals, want %d", got, beforeReconcile)
90
+ }
91
+ rnr.setLive(false)
92
+ created, err = engine.EnsureRun(ctx, start)
93
+ if err != nil || created {
94
+ t.Fatalf("missing-terminal EnsureRun = %v, %v", created, err)
95
+ }
96
+ deadline := time.Now().Add(10 * time.Second)
97
+ for countTemporalSignals(t, engine, start.ID, runID, reconcileSignalName) <= beforeReconcile {
98
+ if time.Now().After(deadline) {
99
+ t.Fatal("missing terminal reconciliation did not persist a reconcile signal")
100
+ }
101
+ time.Sleep(100 * time.Millisecond)
102
+ }
103
+ description, err = engine.client.DescribeWorkflowExecution(ctx, string(start.ID), "")
104
+ if err != nil || description.WorkflowExecutionInfo == nil || description.WorkflowExecutionInfo.Execution == nil || description.WorkflowExecutionInfo.Execution.RunId != runID {
105
+ t.Fatalf("duplicate EnsureRun changed execution: %#v, %v", description.WorkflowExecutionInfo, err)
106
+ }
107
+ workflowSnapshot, err := engine.workflowFromHistory(ctx, start.ID, runID)
108
+ if err != nil || workflowSnapshot.Name != workflow.Name || workflowSnapshot.Nodes["work"].Description != "work" {
109
+ t.Fatalf("Temporal workflow snapshot = %#v, %v", workflowSnapshot, err)
110
+ }
111
+ reportState, err := engine.queryReportState(ctx, start.ID, "not-yet-processed")
112
+ if err != nil || reportState.State != run.StateWaiting || reportState.CurrentNode != "work" || reportState.CurrentNodeVisitID == "" || reportState.Processed {
113
+ t.Fatalf("Temporal report-state snapshot = %#v, %v", reportState, err)
114
+ }
115
+ if _, err := engine.runs.DB.ExecContext(ctx, `DELETE FROM relay_runs WHERE id = ?`, string(start.ID)); err != nil {
116
+ t.Fatal(err)
117
+ }
118
+ created, err = engine.EnsureRun(ctx, start)
119
+ if err != nil || created {
120
+ t.Fatalf("EnsureRun after projection loss = %v, %v", created, err)
121
+ }
122
+ if got, err := engine.runs.Get(ctx, start.ID); err != nil || got.CurrentNode != "work" || got.CurrentNodeVisitID == "" {
123
+ t.Fatalf("reconciled missing projection = %+v, %v", got, err)
124
+ }
125
+
126
+ // A false visible entry models Visibility exposing only an older closed
127
+ // history for this Workflow ID. Recovery must still use exact DescribeWorkflowExecution
128
+ // and restore the currently running execution, never start a replacement.
129
+ if err := engine.reconcileClaimedParents(ctx, map[string]bool{string(start.ID): false}); err != nil {
130
+ t.Fatalf("reconcile visibility lag: %v", err)
131
+ }
132
+ got, err := engine.runs.Get(ctx, start.ID)
133
+ if err != nil {
134
+ t.Fatal(err)
135
+ }
136
+ if got.ID != start.ID || got.CurrentNode != "work" || got.State != run.StateWaiting || got.CurrentNodeVisitID == "" {
137
+ t.Fatalf("reconciled projection = %+v", got)
138
+ }
139
+ description, err = engine.client.DescribeWorkflowExecution(ctx, string(start.ID), "")
140
+ if err != nil {
141
+ t.Fatal(err)
142
+ }
143
+ if description.WorkflowExecutionInfo == nil || description.WorkflowExecutionInfo.Execution == nil || description.WorkflowExecutionInfo.Execution.RunId != runID {
144
+ t.Fatalf("visibility-lag reconciliation changed execution: %#v", description.WorkflowExecutionInfo)
145
+ }
146
+ if got := sys.pollCountValue(); got != 1 {
147
+ t.Fatalf("task-system Poll calls = %d, want one read-only reconciliation poll", got)
148
+ }
149
+ }
150
+
151
+ func TestTemporalRecoveryReconcilesClosedHistoryToCurrentExecution(t *testing.T) {
152
+ if os.Getenv("RELAY_FLOW_TEMPORAL_LIVE") != "1" {
153
+ t.Skip("set RELAY_FLOW_TEMPORAL_LIVE=1 to run closed-history Visibility lag coverage")
154
+ }
155
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
156
+ defer cancel()
157
+ namespace := "relay-flow-closed-lag-" + string(identity.NewNodeVisitID())[:12]
158
+ if err := ensureSpikeNamespace(ctx, "localhost:7233", namespace); err != nil {
159
+ t.Fatal(err)
160
+ }
161
+ path := filepath.Join(t.TempDir(), "state.db")
162
+ if err := projection.InitDatabaseWithIdentity(path, projection.ExecutorIdentity{ExecutorPlugin: "temporal", TemporalAddress: "localhost:7233", TemporalNamespace: namespace}); err != nil {
163
+ t.Fatal(err)
164
+ }
165
+ sys := &lagTaskSystem{}
166
+ registry := repo.NewRegistry()
167
+ wf := lagWorkflow()
168
+ registry.Replace(&repo.Repo{Name: "repo", Path: "/repo", TaskSystem: sys, Workflows: []repo.WorkflowBinding{{Workflow: &wf}}})
169
+ engine, err := New(path, Dependencies{Repos: registry, Runner: &lagRunner{}, Harness: &lagHarness{}, TaskSystem: "lag-task", TemporalAddress: "localhost:7233", TemporalNamespace: namespace})
170
+ if err != nil {
171
+ t.Fatal(err)
172
+ }
173
+ if err := engine.Start(ctx); err != nil {
174
+ t.Fatal(err)
175
+ }
176
+ defer engine.Shutdown(context.Background())
177
+ start := run.Start{ID: identity.NewRunID("repo", wf.Name, "LAG-1"), Repo: "repo", RepoPath: "/repo", Workflow: wf, Ticket: task.TicketRef{ID: "LAG-1", Key: "LAG-1", Title: "Closed history lag"}}
178
+ badWorkflow := wf
179
+ badWorkflow.Nodes = make(map[string]domainworkflow.Node, len(wf.Nodes))
180
+ for name, node := range wf.Nodes {
181
+ badWorkflow.Nodes[name] = node
182
+ }
183
+ badWorkflow.Nodes["start"] = domainworkflow.Node{}
184
+ badStart := start
185
+ badStart.Workflow = badWorkflow
186
+ failed, err := engine.client.ExecuteWorkflow(ctx, client.StartWorkflowOptions{ID: string(start.ID), TaskQueue: TaskQueue, WorkflowIDReusePolicy: enumspb.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE_FAILED_ONLY}, TicketWorkflow, badStart)
187
+ if err != nil {
188
+ t.Fatal(err)
189
+ }
190
+ if err := failed.Get(ctx, nil); err == nil {
191
+ t.Fatal("malformed first execution unexpectedly succeeded")
192
+ }
193
+ current, err := engine.client.ExecuteWorkflow(ctx, client.StartWorkflowOptions{ID: string(start.ID), TaskQueue: TaskQueue, WorkflowIDReusePolicy: enumspb.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE_FAILED_ONLY}, TicketWorkflow, start)
194
+ if err != nil {
195
+ t.Fatal(err)
196
+ }
197
+ waitForLagState(t, ctx, engine, start.ID, run.StateWaiting)
198
+ if err := engine.reconcileClaimedParents(ctx, map[string]bool{string(start.ID): false}); err != nil {
199
+ t.Fatalf("reconcile closed-history lag: %v", err)
200
+ }
201
+ got, err := engine.runs.Get(ctx, start.ID)
202
+ if err != nil {
203
+ t.Fatal(err)
204
+ }
205
+ if got.State != run.StateWaiting || got.CurrentNode != "work" || got.CurrentNodeVisitID == "" {
206
+ t.Fatalf("reconciled current execution projection = %+v", got)
207
+ }
208
+ description, err := engine.client.DescribeWorkflowExecution(ctx, string(start.ID), "")
209
+ if err != nil || description.WorkflowExecutionInfo == nil || description.WorkflowExecutionInfo.Execution == nil || description.WorkflowExecutionInfo.Execution.RunId != current.GetRunID() {
210
+ t.Fatalf("reconciliation changed current execution: %#v, %v", description.WorkflowExecutionInfo, err)
211
+ }
212
+ }
213
+
214
+ func TestTemporalConflictRetryBlocksThenResumes(t *testing.T) {
215
+ if os.Getenv("RELAY_FLOW_TEMPORAL_LIVE") != "1" {
216
+ t.Skip("set RELAY_FLOW_TEMPORAL_LIVE=1 to run Temporal conflict retry against the local server")
217
+ }
218
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
219
+ defer cancel()
220
+ namespace := "relay-flow-conflict-" + string(identity.NewNodeVisitID())[:12]
221
+ if err := ensureSpikeNamespace(ctx, "localhost:7233", namespace); err != nil {
222
+ t.Fatal(err)
223
+ }
224
+ path := filepath.Join(t.TempDir(), "state.db")
225
+ if err := projection.InitDatabaseWithIdentity(path, projection.ExecutorIdentity{ExecutorPlugin: "temporal", TemporalAddress: "localhost:7233", TemporalNamespace: namespace}); err != nil {
226
+ t.Fatal(err)
227
+ }
228
+ sys := &lagTaskSystem{applyFailures: 1}
229
+ registry := repo.NewRegistry()
230
+ wf := lagWorkflow()
231
+ registry.Replace(&repo.Repo{Name: "repo", Path: "/repo", TaskSystem: sys, Workflows: []repo.WorkflowBinding{{Workflow: &wf}}})
232
+ engine, err := New(path, Dependencies{Repos: registry, Runner: &lagRunner{}, Harness: &lagHarness{}, TaskSystem: "lag-task", TemporalAddress: "localhost:7233", TemporalNamespace: namespace})
233
+ if err != nil {
234
+ t.Fatal(err)
235
+ }
236
+ if err := engine.Start(ctx); err != nil {
237
+ t.Fatal(err)
238
+ }
239
+ defer engine.Shutdown(context.Background())
240
+ start := run.Start{ID: identity.NewRunID("repo", wf.Name, "CONFLICT-1"), Repo: "repo", RepoPath: "/repo", Workflow: wf, Ticket: task.TicketRef{ID: "CONFLICT-1", Key: "CONFLICT-1", Title: "Conflict"}}
241
+ if created, err := engine.EnsureRun(ctx, start); err != nil || !created {
242
+ t.Fatalf("EnsureRun = %v, %v", created, err)
243
+ }
244
+ deadline := time.Now().Add(20 * time.Second)
245
+ for {
246
+ got, err := engine.runs.Get(ctx, start.ID)
247
+ if err == nil && got.State == run.StateBlocked {
248
+ snapshot, queryErr := engine.queryRunState(ctx, start.ID, "")
249
+ if queryErr == nil && snapshot.Run.Retry != nil && snapshot.Run.State == run.StateBlocked {
250
+ break
251
+ }
252
+ }
253
+ if time.Now().After(deadline) {
254
+ t.Fatalf("run never became blocked with retry metadata: %+v, %v", got, err)
255
+ }
256
+ time.Sleep(100 * time.Millisecond)
257
+ }
258
+ waitForLagState(t, ctx, engine, start.ID, run.StateWaiting)
259
+ got, err := engine.runs.Get(ctx, start.ID)
260
+ if err != nil {
261
+ t.Fatal(err)
262
+ }
263
+ if got.Retry != nil {
264
+ t.Fatalf("retry metadata remained after conflict recovery: %+v", got.Retry)
265
+ }
266
+ }
267
+
268
+ func lagWorkflow() domainworkflow.Workflow {
269
+ return domainworkflow.Workflow{
270
+ Name: "lagFlow", Repos: []string{"repo"},
271
+ Nodes: map[string]domainworkflow.Node{
272
+ "start": {OnSuccess: []domainworkflow.Route{{Target: "work"}}},
273
+ "work": {
274
+ Type: domainworkflow.NodeAgent, Agent: "agent", Description: "work",
275
+ OnSuccess: []domainworkflow.Route{{Target: "end"}},
276
+ OnFailure: []domainworkflow.Route{{Target: "end"}},
277
+ },
278
+ "end": {},
279
+ },
280
+ }
281
+ }
282
+
283
+ func countTemporalSignals(t *testing.T, engine *Engine, id run.ID, temporalRunID, signalName string) int {
284
+ t.Helper()
285
+ iterator := engine.client.GetWorkflowHistory(context.Background(), string(id), temporalRunID, false, enumspb.HISTORY_EVENT_FILTER_TYPE_ALL_EVENT)
286
+ count := 0
287
+ for iterator.HasNext() {
288
+ event, err := iterator.Next()
289
+ if err != nil {
290
+ t.Fatalf("read Temporal history for signal count: %v", err)
291
+ }
292
+ if event.GetEventType() == enumspb.EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED && event.GetWorkflowExecutionSignaledEventAttributes().GetSignalName() == signalName {
293
+ count++
294
+ }
295
+ }
296
+ return count
297
+ }
298
+
299
+ func waitForLagState(t *testing.T, ctx context.Context, engine *Engine, id run.ID, state run.State) {
300
+ t.Helper()
301
+ for {
302
+ snapshot, err := engine.queryRunState(ctx, id, "")
303
+ if err == nil && snapshot.Run.State == state && snapshot.Run.CurrentNode != "" {
304
+ return
305
+ }
306
+ select {
307
+ case <-ctx.Done():
308
+ t.Fatalf("waiting for Temporal state %s: %v", state, ctx.Err())
309
+ case <-time.After(100 * time.Millisecond):
310
+ }
311
+ }
312
+ }
313
+
314
+ type lagTaskSystem struct {
315
+ mu sync.Mutex
316
+ polls int
317
+ applyFailures int
318
+ }
319
+
320
+ var _ task.System = (*lagTaskSystem)(nil)
321
+
322
+ func (s *lagTaskSystem) Poll(context.Context) ([]task.Ticket, error) {
323
+ s.mu.Lock()
324
+ s.polls++
325
+ s.mu.Unlock()
326
+ return []task.Ticket{{ID: "LAG-1", Key: "LAG-1", Title: "Visibility lag", WorkflowClaims: []string{"wf:lagFlow"}}}, nil
327
+ }
328
+ func (*lagTaskSystem) CompileFilter(config.RawValues) (func(task.Ticket) bool, error) {
329
+ return func(task.Ticket) bool { return true }, nil
330
+ }
331
+ func (*lagTaskSystem) Claim(context.Context, task.TicketRef, string) error { return nil }
332
+ func (*lagTaskSystem) ValidateConfig(context.Context, config.RawValues, map[string]config.RawValues) error {
333
+ return nil
334
+ }
335
+ func (*lagTaskSystem) RenderText(task.TextKind, task.TextData) (string, error) { return "", nil }
336
+ func (*lagTaskSystem) EnsureMailboxes(_ context.Context, _ task.TicketRef, _ string, specs []task.MailboxSpec) (map[string]task.Mailbox, error) {
337
+ out := make(map[string]task.Mailbox, len(specs))
338
+ for _, spec := range specs {
339
+ out[spec.Node] = task.Mailbox{ID: "mb-" + spec.Node, Key: spec.Title, Node: spec.Node}
340
+ }
341
+ return out, nil
342
+ }
343
+ func (s *lagTaskSystem) ApplyTaskConfig(context.Context, task.Target, config.RawValues) error {
344
+ s.mu.Lock()
345
+ defer s.mu.Unlock()
346
+ if s.applyFailures > 0 {
347
+ s.applyFailures--
348
+ return retry.ConflictError(errors.New("task status is not yet compatible"))
349
+ }
350
+ return nil
351
+ }
352
+ func (*lagTaskSystem) CompleteMailbox(context.Context, task.Mailbox) error { return nil }
353
+ func (*lagTaskSystem) HasComment(context.Context, task.Target, string) (bool, error) {
354
+ return false, nil
355
+ }
356
+ func (*lagTaskSystem) Comment(context.Context, task.Target, string, string) error { return nil }
357
+ func (*lagTaskSystem) ResetForRecovery(context.Context, task.TicketRef, []task.Mailbox, config.RawValues) error {
358
+ return nil
359
+ }
360
+ func (s *lagTaskSystem) pollCountValue() int { s.mu.Lock(); defer s.mu.Unlock(); return s.polls }
361
+
362
+ type lagRunner struct {
363
+ mu sync.Mutex
364
+ live bool
365
+ }
366
+
367
+ var _ runner.Runner = (*lagRunner)(nil)
368
+
369
+ func (*lagRunner) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) { return nil, nil }
370
+ func (*lagRunner) ValidateRepo(context.Context, string, string) error { return nil }
371
+ func (*lagRunner) EnsureEnvironment(context.Context, runner.RunSpec) (runner.Environment, error) {
372
+ return runner.Environment{ID: "env-lag", Path: "/repo"}, nil
373
+ }
374
+ func (*lagRunner) SetEnvironmentStatus(context.Context, runner.Environment, string) error { return nil }
375
+ func (r *lagRunner) setLive(live bool) {
376
+ r.mu.Lock()
377
+ r.live = live
378
+ r.mu.Unlock()
379
+ }
380
+
381
+ func (r *lagRunner) FindTerminal(_ context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
382
+ r.mu.Lock()
383
+ live := r.live
384
+ r.mu.Unlock()
385
+ if live && terminal.ID != "" {
386
+ return terminal, true, nil
387
+ }
388
+ return runner.Terminal{}, false, nil
389
+ }
390
+ func (*lagRunner) CreateTerminal(_ context.Context, _ runner.Environment, title string, _ runner.Command) (runner.Terminal, error) {
391
+ return runner.Terminal{ID: "term-" + title, Title: title}, nil
392
+ }
393
+ func (*lagRunner) EnsureTerminal(_ context.Context, _ runner.Environment, _ runner.Terminal, title string, _ runner.Command) (runner.Terminal, error) {
394
+ return runner.Terminal{ID: "term-" + title, Title: title}, nil
395
+ }
396
+ func (*lagRunner) SendTerminal(context.Context, runner.Terminal, string) error { return nil }
397
+ func (*lagRunner) CloseTerminal(context.Context, runner.Terminal) error { return nil }
398
+ func (*lagRunner) CloseTerminals(context.Context, runner.RunSpec) error { return nil }
399
+ func (*lagRunner) CleanupRun(context.Context, runner.RunSpec) error { return nil }
400
+
401
+ type lagHarness struct{}
402
+
403
+ var _ harness.Harness = (*lagHarness)(nil)
404
+
405
+ func (*lagHarness) SetupRepo(context.Context, string) error { return nil }
406
+ func (*lagHarness) FindSession(context.Context, string, string) (harness.Session, bool, error) {
407
+ return harness.Session{}, false, nil
408
+ }
409
+ func (*lagHarness) ValidateAgent(context.Context, string, string) error { return nil }
410
+ func (*lagHarness) RenderPrompt(harness.PromptKind, harness.PromptData, string) (string, error) {
411
+ return "prompt", nil
412
+ }
413
+ func (*lagHarness) BuildCommand(harness.LaunchSpec) (runner.Command, error) {
414
+ return runner.Command{Executable: "agent"}, nil
415
+ }
@@ -28,7 +28,9 @@ const (
28
28
  defaultInitialPrompt = `Task system: {{taskSystem}}
29
29
  Use the {{taskSystem}} tools to read the parent ticket {{ticket}}.
30
30
 
31
- Your mailbox is {{mailbox}}. Read its description and comments for node instructions and feedback.`
31
+ Your mailbox is {{mailbox}}. Read its description and comments for node instructions and feedback.
32
+
33
+ Keep the summary brief, and make the feedback as detailed and actionable as possible for the next agent.`
32
34
  defaultFeedbackPrompt = `New feedback was added to the comments section of your mailbox subtask {{mailbox}}. Read it.`
33
35
  defaultHITLPrompt = `Return 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.`
34
36
  )
@@ -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.4-alpha"
17
+ const configuredPlugin = "relay-flow-plugin@0.2.5-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.4-alpha"
13
+ const relayFlowPlugin = "relay-flow-plugin@0.2.5-alpha"
14
14
 
15
15
  type jsoncToken struct {
16
16
  kind byte
@@ -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)
@@ -198,13 +201,13 @@ func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
198
201
  "RELAY_FLOW_NEXT_STEPS_JSON": string(nextSteps),
199
202
  }
200
203
  args := []string{"--name", spec.Title}
201
- if rolePath != "" {
202
- args = append(args, "--append-system-prompt", rolePath)
204
+ if spec.Agent != "default" {
205
+ args = append(args, "--prompt-template", promptTemplatePath(spec.Agent))
203
206
  }
204
207
  if spec.ResumeID != "" {
205
208
  args = append(args, "--session-id", spec.ResumeID)
206
209
  }
207
- args = append(args, spec.Prompt)
210
+ args = append(args, applyPromptTemplate(spec.Agent, spec.Prompt))
208
211
  return runner.Command{
209
212
  Executable: "pi",
210
213
  Args: args,