relay-flow 0.2.1-alpha → 0.2.3-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 (83) hide show
  1. package/README.md +193 -25
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +79 -1
  4. package/cmd/relay-flow/main.go +50 -1
  5. package/cmd/relay-flow/scenario_test.go +48 -12
  6. package/cmd/relay-flow/serve.go +16 -2
  7. package/examples/beads-workflow.yaml +74 -0
  8. package/examples/default-story-workflow.yaml +6 -6
  9. package/go.mod +2 -1
  10. package/go.sum +2 -0
  11. package/internal/config/config.go +13 -2
  12. package/internal/config/merge_test.go +18 -0
  13. package/internal/execution/goworkflows/activities.go +124 -56
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine.go +20 -0
  16. package/internal/execution/goworkflows/engine_test.go +33 -15
  17. package/internal/execution/goworkflows/fakes_test.go +84 -10
  18. package/internal/execution/goworkflows/interpreter.go +84 -30
  19. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  20. package/internal/execution/goworkflows/node_runtime_test.go +30 -5
  21. package/internal/execution/goworkflows/projection.go +52 -11
  22. package/internal/execution/goworkflows/recovery_test.go +131 -2
  23. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  24. package/internal/harness/contract_test.go +10 -0
  25. package/internal/harness/factory.go +25 -3
  26. package/internal/harness/harness.go +30 -4
  27. package/internal/harness/opencode/opencode.go +125 -10
  28. package/internal/harness/opencode/opencode_test.go +238 -0
  29. package/internal/harness/opencode/repo_setup.go +401 -0
  30. package/internal/harness/plugin_selection_test.go +5 -5
  31. package/internal/identity/identity.go +28 -1
  32. package/internal/identity/identity_test.go +40 -0
  33. package/internal/recover/recover.go +11 -6
  34. package/internal/repo/service.go +10 -0
  35. package/internal/repo/service_test.go +51 -2
  36. package/internal/run/manager.go +193 -25
  37. package/internal/run/run.go +30 -10
  38. package/internal/run/run_manager_test.go +100 -0
  39. package/internal/server/api_test.go +54 -0
  40. package/internal/server/client.go +11 -0
  41. package/internal/server/fixture_test.go +18 -0
  42. package/internal/server/server.go +16 -0
  43. package/internal/task/beads/bdcli/bdcli.go +323 -0
  44. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  45. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  46. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  47. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  48. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  49. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  50. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  51. package/internal/task/beads/bdcli/testdata/empty.json +1 -0
  52. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  53. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  54. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  55. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  56. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  57. package/internal/task/beads/beads.go +856 -0
  58. package/internal/task/beads/beads_test.go +609 -0
  59. package/internal/task/beads/comments_test.go +242 -0
  60. package/internal/task/beads/config_compatibility_test.go +163 -0
  61. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  62. package/internal/task/beads/repo_composition_test.go +232 -0
  63. package/internal/task/beads/runtime_config_test.go +81 -0
  64. package/internal/task/beads/status_compatibility_test.go +276 -0
  65. package/internal/task/beads/status_test.go +257 -0
  66. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  67. package/internal/task/beads/validation_test.go +110 -0
  68. package/internal/task/contract_test.go +10 -0
  69. package/internal/task/factory.go +37 -4
  70. package/internal/task/jira/auth.go +27 -1
  71. package/internal/task/jira/auth_test.go +54 -1
  72. package/internal/task/jira/filters_test.go +60 -0
  73. package/internal/task/jira/helpers_test.go +3 -0
  74. package/internal/task/jira/jira.go +187 -29
  75. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  76. package/internal/task/jira/rest/adf.go +119 -82
  77. package/internal/task/jira/rest/adf_test.go +60 -0
  78. package/internal/task/jira/rest/client_test.go +1 -1
  79. package/internal/task/jira/templates_test.go +118 -0
  80. package/internal/task/jira/transition_defaults_test.go +47 -2
  81. package/internal/task/task.go +40 -0
  82. package/internal/workflow/report_test.go +45 -0
  83. package/package.json +1 -1
@@ -266,6 +266,91 @@ func TestSummaryCurrentFeedbackSelectedNextOnly(t *testing.T) {
266
266
  }
267
267
  }
268
268
 
269
+ func TestTaskSystemTemplatesRenderMailboxAndSplitOneReport(t *testing.T) {
270
+ log := newEventLog()
271
+ sys := newFakeTaskSystem(log)
272
+ seen := map[task.TextKind][]task.TextData{}
273
+ sys.renderText = func(kind task.TextKind, data task.TextData) (string, error) {
274
+ seen[kind] = append(seen[kind], data)
275
+ switch kind {
276
+ case task.TextMailboxDescription:
277
+ return "custom mailbox " + data.Node + " work=" + data.NodeDescription, nil
278
+ case task.TextSummaryComment:
279
+ return "custom summary node=" + data.Node + " mailbox=" + data.Mailbox + "\n" + data.SummaryReport, nil
280
+ case task.TextFeedbackComment:
281
+ return "custom feedback source=" + data.SourceNode + " target=" + data.TargetNode + " mailbox=" + data.Mailbox + "\n" + data.FeedbackReport, nil
282
+ default:
283
+ return "", nil
284
+ }
285
+ }
286
+ engine := newEngine(t, goworkflows.Dependencies{
287
+ Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log), TaskSystem: "custom-task",
288
+ })
289
+ rid, _ := startRun(engine, threeNodeWorkflow())
290
+ waitFor(t, 10*time.Second, func() bool {
291
+ r, _ := engine.GetRun(context.Background(), rid)
292
+ return r.CurrentNode == "exploration"
293
+ })
294
+ if got := sys.specs[1].Description; !strings.Contains(got, "custom mailbox exploration work=explore the code") || !strings.Contains(got, "Required report format:") {
295
+ t.Fatalf("rendered mailbox description = %q", got)
296
+ }
297
+ var explorationData task.TextData
298
+ for _, data := range seen[task.TextMailboxDescription] {
299
+ if data.Node == "exploration" {
300
+ explorationData = data
301
+ }
302
+ }
303
+ for name, got := range map[string]string{
304
+ "runID": explorationData.RunID,
305
+ "ticket": explorationData.Ticket, "workflow": explorationData.Workflow,
306
+ "repo": explorationData.Repo, "node": explorationData.Node,
307
+ "nodeType": explorationData.NodeType, "agent": explorationData.Agent,
308
+ "nodeDescription": explorationData.NodeDescription, "mailbox": explorationData.Mailbox,
309
+ } {
310
+ if got == "" {
311
+ t.Fatalf("mailbox template value %s was empty: %+v", name, explorationData)
312
+ }
313
+ }
314
+ for name, got := range map[string]string{"nextSteps": explorationData.NextSteps, "successRoutes": explorationData.SuccessRoutes, "failureRoutes": explorationData.FailureRoutes} {
315
+ if !strings.Contains(got, "coding") && name != "failureRoutes" {
316
+ t.Fatalf("mailbox template %s = %q", name, got)
317
+ }
318
+ if name == "failureRoutes" && !strings.Contains(got, "exploration") {
319
+ t.Fatalf("mailbox template failureRoutes = %q", got)
320
+ }
321
+ }
322
+ report := successReport("coding")
323
+ report.Feedback = workflow.Feedback{ReasonForNextStep: "reviewed", RequiredActions: "implement", RelevantContext: "ctx", ExpectedResult: "done"}
324
+ if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "exploration", report)); err != nil {
325
+ t.Fatal(err)
326
+ }
327
+ waitFor(t, 10*time.Second, func() bool {
328
+ r, _ := engine.GetRun(context.Background(), rid)
329
+ return r.CurrentNode == "coding"
330
+ })
331
+ summary := sys.commentBodies("PAY-101-exploration")
332
+ feedback := sys.commentBodies("PAY-101-coding")
333
+ if len(summary) != 1 || !strings.Contains(summary[0].Body, "custom summary node=exploration mailbox=PAY-101-exploration") || !strings.Contains(summary[0].Body, "COMPLETED:") {
334
+ t.Fatalf("summary comments = %+v", summary)
335
+ }
336
+ if len(feedback) != 1 || !strings.Contains(feedback[0].Body, "source=exploration target=coding mailbox=PAY-101-coding") || !strings.Contains(feedback[0].Body, "REQUIRED ACTIONS:") {
337
+ t.Fatalf("feedback comments = %+v", feedback)
338
+ }
339
+ if len(seen[task.TextSummaryComment]) != 1 || seen[task.TextSummaryComment][0].SummaryReport == "" {
340
+ t.Fatalf("summary template data = %+v", seen[task.TextSummaryComment])
341
+ }
342
+ if len(seen[task.TextFeedbackComment]) != 1 {
343
+ t.Fatalf("feedback template data = %+v", seen[task.TextFeedbackComment])
344
+ }
345
+ feedbackData := seen[task.TextFeedbackComment][0]
346
+ if feedbackData.SourceNode != "exploration" || feedbackData.TargetNode != "coding" || feedbackData.Mailbox != "PAY-101-coding" || feedbackData.FeedbackReport == "" {
347
+ t.Fatalf("feedback template data = %+v", feedbackData)
348
+ }
349
+ if len(sys.commentBodies("PAY-101-review")) != 0 {
350
+ t.Fatal("one report sent feedback to an unselected mailbox")
351
+ }
352
+ }
353
+
269
354
  // 3.28: end/mailbox behavior, manual status not routing, HITL lifecycle.
270
355
 
271
356
  func TestManualMailboxStatusDoesNotRouteGraph(t *testing.T) {
@@ -5,6 +5,7 @@ import (
5
5
  "database/sql"
6
6
  "errors"
7
7
  "path/filepath"
8
+ "reflect"
8
9
  "testing"
9
10
  "time"
10
11
 
@@ -222,15 +223,18 @@ func TestEnsureNodeRuntimeUsesDirectIDsAndFallsBackFresh(t *testing.T) {
222
223
  if rt.TerminalID == "" || rt.TerminalID == "dead-term" || rt.SessionID != "dead-session" {
223
224
  t.Fatalf("failed direct IDs not replaced atomically: %+v", rt)
224
225
  }
225
- if fr.findCalls != 1 || fh.buildCalls != 1 || fr.createCalls != 1 {
226
+ if fr.findCalls != 2 || fh.buildCalls != 1 || fr.createCalls != 1 {
226
227
  t.Fatalf("stored-ID replacement calls: find=%d build=%d create=%d", fr.findCalls, fh.buildCalls, fr.createCalls)
227
228
  }
228
- if len(fr.findIDs) != 1 || fr.findIDs[0] != "dead-term" {
229
- t.Fatalf("FindTerminal IDs = %v, want [dead-term]", fr.findIDs)
229
+ if len(fr.findIDs) != 2 || fr.findIDs[0] != "dead-term" || fr.findIDs[1] != "dead-term" {
230
+ t.Fatalf("FindTerminal IDs = %v, want [dead-term dead-term]", fr.findIDs)
230
231
  }
231
232
  if len(fh.resumeIDs) != 1 || fh.resumeIDs[0] != "dead-session" {
232
233
  t.Fatalf("BuildCommand ResumeIDs = %v, want [dead-session]", fh.resumeIDs)
233
234
  }
235
+ if !reflect.DeepEqual(fh.rendered, []harness.PromptKind{harness.PromptInitial}) {
236
+ t.Fatalf("dead-terminal rendered prompts = %v, want initial", fh.rendered)
237
+ }
234
238
  for _, prompt := range fh.prompts {
235
239
  if prompt != "work" {
236
240
  t.Fatalf("same-visit relaunch prompt = %q, want standard prompt only", prompt)
@@ -261,7 +265,7 @@ func TestEnsureNodeRuntimeInitialLaunchAppendsCustomInstructions(t *testing.T) {
261
265
  if err := a.EnsureNodeRuntime(ctx, nw, "", spec, NodeRuntime{}); err != nil {
262
266
  t.Fatal(err)
263
267
  }
264
- if len(fh.prompts) != 1 || fh.prompts[0] != "standard prompt\n\ncustom instructions" {
268
+ if len(fh.prompts) != 1 || fh.prompts[0] != "work\n\ncustom instructions" {
265
269
  t.Fatalf("initial prompt = %q", fh.prompts)
266
270
  }
267
271
  if len(fr.statuses) != 1 || fr.statuses[0] != runner.WorkspaceStatusInReview {
@@ -298,7 +302,7 @@ func TestEnsureNodeRuntimeSendFailureClosesLiveTerminal(t *testing.T) {
298
302
  if fr.closeCalls != 1 || fr.closedIDs[0] != "live-old" {
299
303
  t.Fatalf("old live terminal not closed before replacement: %+v", fr.closedIDs)
300
304
  }
301
- if len(fr.sentTexts) != 1 || fr.sentTexts[0] != "New feedback was added to the comments section of your mailbox subtask PAY-234. Read it.\n\nRead the latest review feedback." {
305
+ if len(fr.sentTexts) != 1 || fr.sentTexts[0] != "feedback\n\nRead the latest review feedback." {
302
306
  t.Fatalf("live revisit prompt = %q", fr.sentTexts)
303
307
  }
304
308
  if len(fh.prompts) != 1 || fh.prompts[0] != "work\n\nRead the latest review feedback." {
@@ -311,6 +315,9 @@ func TestEnsureNodeRuntimeSendFailureClosesLiveTerminal(t *testing.T) {
311
315
  if len(fh.resumeIDs) != 1 || fh.resumeIDs[0] != "session-old" {
312
316
  t.Fatalf("replacement ResumeIDs = %v, want [session-old]", fh.resumeIDs)
313
317
  }
318
+ if !reflect.DeepEqual(fh.rendered, []harness.PromptKind{harness.PromptFeedback, harness.PromptInitial}) {
319
+ t.Fatalf("rendered prompts = %v, want feedback then replacement initial", fh.rendered)
320
+ }
314
321
  }
315
322
 
316
323
  func TestEnsureNodeRuntimeSameVisitSendsNothing(t *testing.T) {
@@ -338,6 +345,10 @@ func TestEnsureNodeRuntimeSameVisitSendsNothing(t *testing.T) {
338
345
  if len(fr.sentTexts) != 0 || fr.createCalls != 0 {
339
346
  t.Fatalf("same visit sent=%q creates=%d", fr.sentTexts, fr.createCalls)
340
347
  }
348
+ fh := a.Harness.(*runtimeTestHarness)
349
+ if len(fh.rendered) != 0 || fh.buildCalls != 0 {
350
+ t.Fatalf("same visit rendered=%v buildCalls=%d, want silence", fh.rendered, fh.buildCalls)
351
+ }
341
352
  }
342
353
 
343
354
  func TestEnsureNodeRuntimeRejectsStaleVisitWithoutLaunch(t *testing.T) {
@@ -487,8 +498,22 @@ type runtimeTestHarness struct {
487
498
  buildCalls int
488
499
  prompts []string
489
500
  resumeIDs []string
501
+ rendered []harness.PromptKind
502
+ }
503
+
504
+ func (h *runtimeTestHarness) RenderPrompt(kind harness.PromptKind, _ harness.PromptData, nudge string) (string, error) {
505
+ h.rendered = append(h.rendered, kind)
506
+ prompt := "work"
507
+ if kind == harness.PromptFeedback {
508
+ prompt = "feedback"
509
+ }
510
+ if nudge != "" {
511
+ prompt += "\n\n" + nudge
512
+ }
513
+ return prompt, nil
490
514
  }
491
515
 
516
+ func (*runtimeTestHarness) SetupRepo(context.Context, string) error { return nil }
492
517
  func (*runtimeTestHarness) ValidateAgent(context.Context, string, string) error { return nil }
493
518
  func (*runtimeTestHarness) FindSession(context.Context, string, string) (harness.Session, bool, error) {
494
519
  return harness.Session{}, false, nil
@@ -34,6 +34,8 @@ type NodeRuntime struct {
34
34
  const relayRunsSchema = `
35
35
  CREATE TABLE IF NOT EXISTS relay_runs (
36
36
  id TEXT PRIMARY KEY,
37
+ logical_run_id TEXT,
38
+ attempt_id INTEGER,
37
39
  repo TEXT NOT NULL,
38
40
  workflow TEXT NOT NULL,
39
41
  ticket_id TEXT NOT NULL,
@@ -86,9 +88,11 @@ func (p *RunProjection) migrate() error {
86
88
  return err
87
89
  }
88
90
  for name, definition := range map[string]string{
89
- "retry_error": "TEXT",
90
- "retry_attempt": "INTEGER",
91
- "next_retry_at": "DATETIME",
91
+ "logical_run_id": "TEXT",
92
+ "attempt_id": "INTEGER",
93
+ "retry_error": "TEXT",
94
+ "retry_attempt": "INTEGER",
95
+ "next_retry_at": "DATETIME",
92
96
  } {
93
97
  var count int
94
98
  if err := p.DB.QueryRow(`SELECT COUNT(1) FROM pragma_table_info('relay_runs') WHERE name = ?`, name).Scan(&count); err != nil {
@@ -100,6 +104,15 @@ func (p *RunProjection) migrate() error {
100
104
  }
101
105
  }
102
106
  }
107
+ // Rows created before attempt identities were introduced represent the
108
+ // original attempt. Backfill the stable logical ID and attempt number so
109
+ // restart allocation remains numeric and never reuses attempt 1.
110
+ if _, err := p.DB.Exec(`UPDATE relay_runs SET logical_run_id = id WHERE COALESCE(logical_run_id, '') = ''`); err != nil {
111
+ return err
112
+ }
113
+ if _, err := p.DB.Exec(`UPDATE relay_runs SET attempt_id = 1 WHERE attempt_id IS NULL OR attempt_id = 0`); err != nil {
114
+ return err
115
+ }
103
116
  return nil
104
117
  }
105
118
 
@@ -110,11 +123,19 @@ var errNodeRuntimeNotFound = errors.New("node runtime not found")
110
123
  func IsNotFound(err error) bool { return errors.Is(err, errRunNotFound) }
111
124
 
112
125
  func (p *RunProjection) insertStart(ctx context.Context, s run.Start, now time.Time) error {
126
+ logicalID := s.LogicalID
127
+ if logicalID == "" {
128
+ logicalID = s.ID
129
+ }
130
+ attemptID := s.AttemptID
131
+ if attemptID == 0 {
132
+ attemptID = 1
133
+ }
113
134
  _, err := p.DB.ExecContext(ctx, `
114
- INSERT INTO relay_runs (id, repo, workflow, ticket_id, ticket_key, state, started_at, updated_at)
115
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
135
+ INSERT INTO relay_runs (id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, started_at, updated_at)
136
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
116
137
  ON CONFLICT(id) DO NOTHING`,
117
- string(s.ID), s.Repo, s.Workflow.Name, s.Ticket.ID, s.Ticket.Key,
138
+ string(s.ID), string(logicalID), int64(attemptID), s.Repo, s.Workflow.Name, s.Ticket.ID, s.Ticket.Key,
118
139
  string(run.StateStarting), now, now)
119
140
  return err
120
141
  }
@@ -360,7 +381,7 @@ func nullableString(value string) any {
360
381
 
361
382
  func (p *RunProjection) get(ctx context.Context, id run.ID) (run.Run, error) {
362
383
  row := p.DB.QueryRowContext(ctx, `
363
- SELECT id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error,
384
+ SELECT id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error,
364
385
  retry_error, retry_attempt, next_retry_at, started_at, updated_at, finished_at
365
386
  FROM relay_runs WHERE id = ?`, string(id))
366
387
  return scanRun(row)
@@ -368,9 +389,17 @@ func (p *RunProjection) get(ctx context.Context, id run.ID) (run.Run, error) {
368
389
 
369
390
  func (p *RunProjection) findByTicket(ctx context.Context, ticket string) (run.Run, error) {
370
391
  row := p.DB.QueryRowContext(ctx, `
371
- SELECT id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error,
392
+ SELECT id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error,
372
393
  retry_error, retry_attempt, next_retry_at, started_at, updated_at, finished_at
373
- FROM relay_runs WHERE ticket_key = ? ORDER BY started_at DESC LIMIT 1`, ticket)
394
+ FROM relay_runs WHERE ticket_key = ? ORDER BY started_at DESC, attempt_id DESC LIMIT 1`, ticket)
395
+ return scanRun(row)
396
+ }
397
+
398
+ func (p *RunProjection) findByLogicalID(ctx context.Context, logicalID run.ID) (run.Run, error) {
399
+ row := p.DB.QueryRowContext(ctx, `
400
+ SELECT id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error,
401
+ retry_error, retry_attempt, next_retry_at, started_at, updated_at, finished_at
402
+ FROM relay_runs WHERE logical_run_id = ? ORDER BY started_at DESC, attempt_id DESC LIMIT 1`, string(logicalID))
374
403
  return scanRun(row)
375
404
  }
376
405
 
@@ -380,11 +409,13 @@ type rowScanner interface {
380
409
 
381
410
  func scanRun(row rowScanner) (run.Run, error) {
382
411
  var r run.Run
412
+ var logicalID sql.NullString
413
+ var attemptNumber sql.NullInt64
383
414
  var node, visit, lastErr, retryErr sql.NullString
384
415
  var retryAttempt sql.NullInt64
385
416
  var nextRetry, finished sql.NullTime
386
417
  var started, updated time.Time
387
- err := row.Scan(&r.ID, &r.Repo, &r.Workflow, &r.Ticket.ID, &r.Ticket.Key, &r.State,
418
+ err := row.Scan(&r.ID, &logicalID, &attemptNumber, &r.Repo, &r.Workflow, &r.Ticket.ID, &r.Ticket.Key, &r.State,
388
419
  &node, &visit, &lastErr, &retryErr, &retryAttempt, &nextRetry, &started, &updated, &finished)
389
420
  if errors.Is(err, sql.ErrNoRows) {
390
421
  return run.Run{}, errRunNotFound
@@ -392,6 +423,16 @@ func scanRun(row rowScanner) (run.Run, error) {
392
423
  if err != nil {
393
424
  return run.Run{}, err
394
425
  }
426
+ if logicalID.Valid && logicalID.String != "" {
427
+ r.LogicalID = run.ID(logicalID.String)
428
+ } else {
429
+ r.LogicalID = r.ID
430
+ }
431
+ if attemptNumber.Valid && attemptNumber.Int64 > 0 {
432
+ r.AttemptID = run.AttemptID(attemptNumber.Int64)
433
+ } else {
434
+ r.AttemptID = 1
435
+ }
395
436
  r.CurrentNode = node.String
396
437
  r.CurrentNodeVisitID = run.NodeVisitID(visit.String)
397
438
  r.LastError = lastErr.String
@@ -410,7 +451,7 @@ func scanRun(row rowScanner) (run.Run, error) {
410
451
  }
411
452
 
412
453
  func (p *RunProjection) list(ctx context.Context, f run.Filter) ([]run.Run, error) {
413
- q := `SELECT id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error, retry_error, retry_attempt, next_retry_at, started_at, updated_at, finished_at FROM relay_runs WHERE 1=1`
454
+ q := `SELECT id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error, retry_error, retry_attempt, next_retry_at, started_at, updated_at, finished_at FROM relay_runs WHERE 1=1`
414
455
  var args []any
415
456
  if f.Repo != "" {
416
457
  q += ` AND repo = ?`
@@ -5,6 +5,7 @@ import (
5
5
  "database/sql"
6
6
  "os"
7
7
  "path/filepath"
8
+ "strings"
8
9
  "testing"
9
10
  "time"
10
11
 
@@ -219,6 +220,131 @@ func TestCancelRun(t *testing.T) {
219
220
  }
220
221
  }
221
222
 
223
+ func TestExplicitRestartCreatesFreshAttemptFromStart(t *testing.T) {
224
+ log := newEventLog()
225
+ sys := newFakeTaskSystem(log)
226
+ fr := newFakeRunner(log)
227
+ fh := newFakeHarness(log)
228
+ repos := repoRegistryWith("payments", sys)
229
+ wf := linearWorkflow(false)
230
+ workflows := &workflow.Registry{}
231
+ workflows.Replace(&wf)
232
+ engine := newEngine(t, goworkflows.Dependencies{
233
+ Repos: repos, Runner: fr, Harness: fh,
234
+ })
235
+ oldID, err := startRun(engine, wf)
236
+ if err != nil {
237
+ t.Fatal(err)
238
+ }
239
+ waitFor(t, 10*time.Second, func() bool {
240
+ r, _ := engine.GetRun(context.Background(), oldID)
241
+ return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
242
+ })
243
+ if err := engine.CancelRun(context.Background(), oldID, "operator requested restart"); err != nil {
244
+ t.Fatal(err)
245
+ }
246
+ waitFor(t, 30*time.Second, func() bool {
247
+ r, _ := engine.GetRun(context.Background(), oldID)
248
+ return r.State == run.StateCanceled
249
+ })
250
+
251
+ manager := &run.RunManager{
252
+ Executor: engine, Runs: engine, Repos: repos, Workflows: workflows,
253
+ }
254
+ fresh, err := manager.RestartByTicket(context.Background(), "PAY-101")
255
+ if err != nil {
256
+ t.Fatalf("RestartByTicket failed: %v", err)
257
+ }
258
+ if fresh.ID == oldID || fresh.LogicalID != oldID || fresh.AttemptID != 2 {
259
+ t.Fatalf("fresh attempt = %+v, want logical=%q attempt=2 and a new ID", fresh, oldID)
260
+ }
261
+ oldAck, err := engine.SubmitReport(context.Background(), reportRequest(oldID, "coding", successReport("end")))
262
+ if err != nil || !oldAck.Accepted || !oldAck.Duplicate {
263
+ t.Fatalf("stale old-attempt report ack=%+v err=%v, want accepted duplicate", oldAck, err)
264
+ }
265
+ if got := string(fresh.ID); got != string(oldID)+"~attempt~2" {
266
+ t.Fatalf("fresh execution ID = %q, want numeric attempt suffix", got)
267
+ }
268
+ waitFor(t, 30*time.Second, func() bool {
269
+ r, _ := engine.GetRun(context.Background(), fresh.ID)
270
+ return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
271
+ })
272
+
273
+ current, err := engine.GetRun(context.Background(), fresh.ID)
274
+ if err != nil {
275
+ t.Fatal(err)
276
+ }
277
+ latest, err := engine.FindRunByTicket(context.Background(), "PAY-101")
278
+ if err != nil {
279
+ t.Fatal(err)
280
+ }
281
+ if latest.ID != fresh.ID || latest.AttemptID != 2 || latest.LogicalID != oldID {
282
+ t.Fatalf("ticket lookup = %+v, want latest fresh attempt %q", latest, fresh.ID)
283
+ }
284
+ if current.State != run.StateWaiting && current.State != run.StateRunning {
285
+ t.Fatalf("fresh attempt state = %q, want active node state", current.State)
286
+ }
287
+ if got := log.count("prepareRestart:PAY-101"); got != 1 {
288
+ t.Fatalf("restart preparation calls = %d, want 1", got)
289
+ }
290
+ if len(fr.envs) != 1 {
291
+ t.Fatalf("restart created a second ticket environment: %d", len(fr.envs))
292
+ }
293
+ if fr.liveTerminals() != 1 {
294
+ t.Fatalf("restart left %d live terminals, want one fresh node terminal", fr.liveTerminals())
295
+ }
296
+ }
297
+
298
+ func TestRestartStatusConflictIsVisibleAndRecovers(t *testing.T) {
299
+ log := newEventLog()
300
+ sys := newFakeTaskSystem(log)
301
+ fr := newFakeRunner(log)
302
+ repos := repoRegistryWith("payments", sys)
303
+ wf := linearWorkflow(false)
304
+ workflows := &workflow.Registry{}
305
+ workflows.Replace(&wf)
306
+ engine := newEngine(t, goworkflows.Dependencies{Repos: repos, Runner: fr, Harness: newFakeHarness(log)})
307
+ oldID, err := startRun(engine, wf)
308
+ if err != nil {
309
+ t.Fatal(err)
310
+ }
311
+ waitFor(t, 10*time.Second, func() bool {
312
+ r, _ := engine.GetRun(context.Background(), oldID)
313
+ return r.CurrentNode == "coding"
314
+ })
315
+ if err := engine.CancelRun(context.Background(), oldID, "operator requested restart"); err != nil {
316
+ t.Fatal(err)
317
+ }
318
+ waitFor(t, 30*time.Second, func() bool {
319
+ r, _ := engine.GetRun(context.Background(), oldID)
320
+ return r.State == run.StateCanceled
321
+ })
322
+
323
+ sys.setStartConflict(true)
324
+ manager := &run.RunManager{Executor: engine, Runs: engine, Repos: repos, Workflows: workflows}
325
+ fresh, err := manager.RestartByTicket(context.Background(), "PAY-101")
326
+ if err != nil {
327
+ t.Fatal(err)
328
+ }
329
+ waitFor(t, 30*time.Second, func() bool {
330
+ r, _ := engine.GetRun(context.Background(), fresh.ID)
331
+ return r.State == run.StateBlocked
332
+ })
333
+ blocked, err := engine.GetRun(context.Background(), fresh.ID)
334
+ if err != nil {
335
+ t.Fatal(err)
336
+ }
337
+ if !strings.Contains(blocked.LastError, "Move ticket PAY-101 to an allowed active start status") {
338
+ t.Fatalf("blocked LastError = %q, want actionable start-status guidance", blocked.LastError)
339
+ }
340
+
341
+ sys.setStartConflict(false)
342
+ waitFor(t, 60*time.Second, func() bool {
343
+ r, _ := engine.GetRun(context.Background(), fresh.ID)
344
+ return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
345
+ })
346
+ }
347
+
222
348
  func TestCancelDuringRunningActivity(t *testing.T) {
223
349
  // Cancellation cannot interrupt an already-running activity; it waits
224
350
  // for it to return, then runs cancellation cleanup.
@@ -444,6 +570,9 @@ func TestConflictMarksBlockedThenRecovers(t *testing.T) {
444
570
  if r.LastError == "" {
445
571
  t.Fatal("blocked run exposes no conflict error in LastError")
446
572
  }
573
+ if !strings.Contains(r.LastError, "Restore the task-system state required for node coding") {
574
+ t.Fatalf("blocked LastError = %q, want actionable node-state guidance", r.LastError)
575
+ }
447
576
  if sys.mailboxStatusOf("PAY-101-coding") == "Done" {
448
577
  t.Fatal("mailbox completed while state was incompatible; no blind overwrite allowed")
449
578
  }
@@ -759,8 +888,8 @@ func recoverTickets(ctx context.Context, engine *goworkflows.Engine, sys *fakeTa
759
888
  return err
760
889
  }
761
890
  rm := &run.RunManager{Executor: engine, Runs: engine}
762
- specsFor := func(w *workflow.Workflow, key string) []task.MailboxSpec {
763
- return goworkflows.MailboxSpecs(w, key)
891
+ specsFor := func(system task.System, work run.Work, w *workflow.Workflow) ([]task.MailboxSpec, error) {
892
+ return goworkflows.RenderMailboxSpecs(system, work, w)
764
893
  }
765
894
  return recoverpkg.FromTaskSystem(ctx, reg, fr, rm, specsFor)
766
895
  }
@@ -0,0 +1,31 @@
1
+ package goworkflows
2
+
3
+ import (
4
+ "encoding/json"
5
+ "os"
6
+ "testing"
7
+
8
+ "github.com/rajpopat27/relay-flow/internal/run"
9
+ )
10
+
11
+ func TestSharedReportContractFixtureRendersCommentValues(t *testing.T) {
12
+ b, err := os.ReadFile("../../../testdata/report-contract.json")
13
+ if err != nil {
14
+ t.Fatal(err)
15
+ }
16
+ var fixtures map[string]struct {
17
+ Envelope run.ReportRequest `json:"envelope"`
18
+ SummaryReport string `json:"summaryReport"`
19
+ FeedbackReport string `json:"feedbackReport"`
20
+ }
21
+ if err := json.Unmarshal(b, &fixtures); err != nil {
22
+ t.Fatal(err)
23
+ }
24
+ fixture := fixtures["work"]
25
+ if got := renderSummaryReport(fixture.Envelope.Report); got != fixture.SummaryReport {
26
+ t.Fatalf("summaryReport mismatch\ngot:\n%s\nwant:\n%s", got, fixture.SummaryReport)
27
+ }
28
+ if got := renderFeedbackReport(fixture.Envelope.Report); got != fixture.FeedbackReport {
29
+ t.Fatalf("feedbackReport mismatch\ngot:\n%s\nwant:\n%s", got, fixture.FeedbackReport)
30
+ }
31
+ }
@@ -32,6 +32,8 @@ func newFakeHarness() *fakeHarness {
32
32
  }
33
33
  }
34
34
 
35
+ func (f *fakeHarness) SetupRepo(context.Context, string) error { return nil }
36
+
35
37
  func (f *fakeHarness) ValidateAgent(_ context.Context, _, agent string) error {
36
38
  if !f.agents[agent] {
37
39
  return errUnknownAgent
@@ -44,6 +46,14 @@ func (f *fakeHarness) FindSession(_ context.Context, _, title string) (harness.S
44
46
  return s, ok, nil
45
47
  }
46
48
 
49
+ func (f *fakeHarness) RenderPrompt(_ harness.PromptKind, data harness.PromptData, nudge string) (string, error) {
50
+ prompt := data.TaskSystem + ":" + data.Ticket + ":" + data.Mailbox
51
+ if nudge != "" {
52
+ prompt += "\n\n" + nudge
53
+ }
54
+ return prompt, nil
55
+ }
56
+
47
57
  func (f *fakeHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
48
58
  // The fake mirrors the required env contract; the real opencode harness
49
59
  // builds the executable/args. NEXT_STEPS_JSON carries the legal targets
@@ -9,8 +9,12 @@ import (
9
9
  "github.com/rajpopat27/relay-flow/internal/config"
10
10
  )
11
11
 
12
- // Factory constructs a Harness from root harness config.
13
- type Factory func(config.RawValues) (Harness, error)
12
+ // Factory constructs a Harness from root harness config and supplies the
13
+ // selected harness's init defaults without coupling core to an adapter.
14
+ type Factory struct {
15
+ DefaultConfig func() config.RawValues
16
+ New func(config.RawValues) (Harness, error)
17
+ }
14
18
 
15
19
  var (
16
20
  registryMu sync.RWMutex
@@ -35,7 +39,25 @@ func New(name string, cfg config.RawValues) (Harness, error) {
35
39
  if !ok {
36
40
  return nil, fmt.Errorf("harness: unknown plugin %q (registered: %s)", name, strings.Join(Names(), ", "))
37
41
  }
38
- return f(cfg)
42
+ return f.New(config.Merge(defaultConfig(f), cfg))
43
+ }
44
+
45
+ // Defaults returns a fresh copy of the named harness's root config defaults.
46
+ func Defaults(name string) (config.RawValues, error) {
47
+ registryMu.RLock()
48
+ f, ok := registry[name]
49
+ registryMu.RUnlock()
50
+ if !ok {
51
+ return nil, fmt.Errorf("harness: unknown plugin %q (registered: %s)", name, strings.Join(Names(), ", "))
52
+ }
53
+ return config.Merge(defaultConfig(f)), nil
54
+ }
55
+
56
+ func defaultConfig(f Factory) config.RawValues {
57
+ if f.DefaultConfig == nil {
58
+ return nil
59
+ }
60
+ return f.DefaultConfig()
39
61
  }
40
62
 
41
63
  // ValidateName returns an error listing registered names when name is not
@@ -1,7 +1,7 @@
1
- // Package harness defines the harness contract: agent validation, session
2
- // discovery, resume syntax, and launch command construction. The runner
3
- // executes the returned command; the harness never manipulates runner
4
- // state.
1
+ // Package harness defines the harness contract: repository setup, prompt
2
+ // rendering, agent validation, session discovery, resume syntax, and launch
3
+ // command construction. The runner executes the returned command; the harness
4
+ // never manipulates runner state.
5
5
  package harness
6
6
 
7
7
  import (
@@ -17,6 +17,29 @@ type Session struct {
17
17
  Title string
18
18
  }
19
19
 
20
+ type PromptKind string
21
+
22
+ const (
23
+ PromptInitial PromptKind = "initial"
24
+ PromptFeedback PromptKind = "feedback"
25
+ )
26
+
27
+ // PromptData is the task-system-neutral data core supplies to the selected
28
+ // harness. Harness templates, including HITL instructions, are rendered only
29
+ // by the harness.
30
+ type PromptData struct {
31
+ TaskSystem string
32
+ Ticket string
33
+ Workflow string
34
+ Repo string
35
+ Node string
36
+ NodeType workflow.NodeType
37
+ Agent string
38
+ NodeDescription string
39
+ NextSteps string
40
+ Mailbox string
41
+ }
42
+
20
43
  type LaunchSpec struct {
21
44
  RunID identity.RunID
22
45
  NodeVisitID identity.NodeVisitID
@@ -30,12 +53,15 @@ type LaunchSpec struct {
30
53
  Title string
31
54
  Prompt string
32
55
  NudgePrompt string
56
+ PromptData PromptData
33
57
  NextSteps []workflow.Route
34
58
  ResumeID string
35
59
  }
36
60
 
37
61
  type Harness interface {
62
+ SetupRepo(ctx context.Context, repoPath string) error
38
63
  ValidateAgent(ctx context.Context, repoPath, agent string) error
39
64
  FindSession(ctx context.Context, repoPath, title string) (Session, bool, error)
65
+ RenderPrompt(kind PromptKind, data PromptData, nudgeTemplate string) (string, error)
40
66
  BuildCommand(spec LaunchSpec) (runner.Command, error)
41
67
  }