relay-flow 0.2.2-alpha → 0.2.4-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 (77) hide show
  1. package/README.md +154 -13
  2. package/cmd/relay-flow/commands_test.go +5 -1
  3. package/cmd/relay-flow/main.go +27 -1
  4. package/cmd/relay-flow/pi_wiring_test.go +64 -0
  5. package/cmd/relay-flow/serve.go +14 -0
  6. package/examples/beads-workflow.yaml +3 -3
  7. package/examples/config-reference.yaml +144 -0
  8. package/examples/minimal-beads-task-workflow.yaml +34 -0
  9. package/examples/minimal-jira-task-workflow.yaml +68 -0
  10. package/examples/workflow-reference.yaml +111 -0
  11. package/internal/execution/goworkflows/activities.go +19 -0
  12. package/internal/execution/goworkflows/engine.go +20 -0
  13. package/internal/execution/goworkflows/engine_test.go +1 -1
  14. package/internal/execution/goworkflows/fakes_test.go +34 -6
  15. package/internal/execution/goworkflows/interpreter.go +48 -1
  16. package/internal/execution/goworkflows/projection.go +52 -11
  17. package/internal/execution/goworkflows/recovery_test.go +129 -0
  18. package/internal/harness/opencode/opencode.go +4 -4
  19. package/internal/harness/opencode/opencode_test.go +59 -4
  20. package/internal/harness/opencode/repo_setup.go +42 -2
  21. package/internal/harness/pi/config_test.go +46 -0
  22. package/internal/harness/pi/lifecycle_test.go +69 -0
  23. package/internal/harness/pi/pi.go +261 -0
  24. package/internal/harness/pi/pi_test.go +322 -0
  25. package/internal/harness/pi/prompt_test.go +121 -0
  26. package/internal/harness/pi/testdata/pi-0.84.1/capture.json +126 -0
  27. package/internal/harness/pi/testdata/pi-0.84.1/noninteractive-output.txt +11 -0
  28. package/internal/harness/pi/testdata/pi-0.84.1/tui-output-sanitized.txt +17 -0
  29. package/internal/harness/pi/validation_test.go +168 -0
  30. package/internal/identity/identity.go +28 -1
  31. package/internal/identity/identity_test.go +40 -0
  32. package/internal/run/manager.go +193 -25
  33. package/internal/run/run.go +24 -6
  34. package/internal/run/run_manager_test.go +100 -0
  35. package/internal/runner/herdr/baseref.go +60 -0
  36. package/internal/runner/herdr/herdr.go +578 -0
  37. package/internal/runner/herdr/herdr_test.go +688 -0
  38. package/internal/runner/herdr/herdrcli/contract.go +128 -0
  39. package/internal/runner/herdr/herdrcli/exec.go +68 -0
  40. package/internal/runner/herdr/herdrcli/herdrcli_test.go +274 -0
  41. package/internal/runner/herdr/herdrcli/live_test.go +132 -0
  42. package/internal/runner/herdr/herdrcli/operations.go +281 -0
  43. package/internal/runner/herdr/herdrcli/response.go +116 -0
  44. package/internal/runner/herdr/herdrcli/testdata/empty-panes.json +1 -0
  45. package/internal/runner/herdr/herdrcli/testdata/empty-tabs.json +1 -0
  46. package/internal/runner/herdr/herdrcli/testdata/error-not-git-worktree.json +1 -0
  47. package/internal/runner/herdr/herdrcli/testdata/error-pane-not-found.json +1 -0
  48. package/internal/runner/herdr/herdrcli/testdata/error-workspace-not-found.json +1 -0
  49. package/internal/runner/herdr/herdrcli/testdata/error-worktree-not-found.json +1 -0
  50. package/internal/runner/herdr/herdrcli/testdata/malformed.json +1 -0
  51. package/internal/runner/herdr/herdrcli/testdata/pane-close.json +6 -0
  52. package/internal/runner/herdr/herdrcli/testdata/pane-get.json +25 -0
  53. package/internal/runner/herdr/herdrcli/testdata/pane-list.json +45 -0
  54. package/internal/runner/herdr/herdrcli/testdata/pane-process-info-shell.json +22 -0
  55. package/internal/runner/herdr/herdrcli/testdata/pane-process-info.json +23 -0
  56. package/internal/runner/herdr/herdrcli/testdata/pane-rename.json +23 -0
  57. package/internal/runner/herdr/herdrcli/testdata/snapshot.json +213 -0
  58. package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +175 -0
  59. package/internal/runner/herdr/herdrcli/testdata/tab-create.json +31 -0
  60. package/internal/runner/herdr/herdrcli/testdata/tab-list.json +26 -0
  61. package/internal/runner/herdr/herdrcli/testdata/workspace-close.json +6 -0
  62. package/internal/runner/herdr/herdrcli/testdata/worktree-create.json +58 -0
  63. package/internal/runner/herdr/herdrcli/testdata/worktree-list.json +35 -0
  64. package/internal/runner/herdr/herdrcli/testdata/worktree-open.json +59 -0
  65. package/internal/server/api_test.go +54 -0
  66. package/internal/server/client.go +11 -0
  67. package/internal/server/fixture_test.go +18 -0
  68. package/internal/server/server.go +16 -0
  69. package/internal/task/beads/beads.go +16 -16
  70. package/internal/task/beads/config_compatibility_test.go +7 -8
  71. package/internal/task/beads/status_compatibility_test.go +43 -0
  72. package/internal/task/jira/filters_test.go +32 -6
  73. package/internal/task/jira/helpers_test.go +3 -0
  74. package/internal/task/jira/jira.go +43 -17
  75. package/internal/task/jira/transition_defaults_test.go +43 -0
  76. package/internal/task/task.go +8 -0
  77. package/package.json +1 -1
@@ -0,0 +1,168 @@
1
+ package pi
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "path/filepath"
7
+ "strings"
8
+ "testing"
9
+ )
10
+
11
+ func TestPiValidateAgentAcceptsDefaultWithPiOnPATH(t *testing.T) {
12
+ fakeDir := strictPiAvailabilityCLI(t)
13
+ t.Setenv("PATH", fakeDir)
14
+
15
+ h := newPiHarness(t)
16
+ if err := h.ValidateAgent(context.Background(), "/srv/payments", "default"); err != nil {
17
+ t.Fatalf("default agent rejected when pi is available: %v", err)
18
+ }
19
+ }
20
+
21
+ func TestPiValidateAgentRejectsUnavailablePi(t *testing.T) {
22
+ directory := t.TempDir()
23
+ // Keep PATH controlled and empty of both the fake and any host-installed Pi.
24
+ t.Setenv("PATH", directory)
25
+
26
+ h := newPiHarness(t)
27
+ err := h.ValidateAgent(context.Background(), "/srv/payments", "default")
28
+ if err == nil {
29
+ t.Fatal("default agent accepted when pi is unavailable")
30
+ }
31
+ if !strings.Contains(err.Error(), "pi") {
32
+ t.Fatalf("error = %q, want Pi availability context", err)
33
+ }
34
+ }
35
+
36
+ func TestPiValidateAgentAcceptsExistingRoleAndPiOnPath(t *testing.T) {
37
+ fakeDir := strictPiAvailabilityCLI(t)
38
+ t.Setenv("PATH", fakeDir)
39
+ repoPath := t.TempDir()
40
+ rolePath := writePiRole(t, repoPath, "coder", "You are the coder.\n")
41
+
42
+ h := newPiHarness(t)
43
+ if err := h.ValidateAgent(context.Background(), repoPath, "coder"); err != nil {
44
+ t.Fatalf("existing role %q rejected: %v", rolePath, err)
45
+ }
46
+ if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
47
+ t.Fatalf("role validation unexpectedly executed Pi: %v", calls)
48
+ }
49
+ }
50
+
51
+ func TestPiValidateAgentRejectsMissingRoleBeforePiLookup(t *testing.T) {
52
+ fakeDir := strictPiAvailabilityCLI(t)
53
+ t.Setenv("PATH", fakeDir)
54
+
55
+ h := newPiHarness(t)
56
+ err := h.ValidateAgent(context.Background(), t.TempDir(), "reviewer")
57
+ if err == nil || !strings.Contains(err.Error(), ".pi/roles/reviewer.md") {
58
+ t.Fatalf("missing role error = %v, want role path", err)
59
+ }
60
+ if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
61
+ t.Fatalf("missing role invoked Pi lookup: %v", calls)
62
+ }
63
+ }
64
+
65
+ func TestPiValidateAgentRejectsUnsafeRolePath(t *testing.T) {
66
+ fakeDir := strictPiAvailabilityCLI(t)
67
+ t.Setenv("PATH", fakeDir)
68
+
69
+ h := newPiHarness(t)
70
+ for _, role := range []string{"../coder", "subdir/coder", `subdir\\coder`, " ", "."} {
71
+ if err := h.ValidateAgent(context.Background(), t.TempDir(), role); err == nil {
72
+ t.Errorf("unsafe role %q accepted", role)
73
+ }
74
+ }
75
+ if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
76
+ t.Fatalf("unsafe roles invoked Pi lookup: %v", calls)
77
+ }
78
+ }
79
+
80
+ func TestPiValidateAgentRejectsEmptyAndNonRegularRoleFiles(t *testing.T) {
81
+ fakeDir := strictPiAvailabilityCLI(t)
82
+ t.Setenv("PATH", fakeDir)
83
+
84
+ emptyRepo := t.TempDir()
85
+ emptyPath := filepath.Join(emptyRepo, ".pi", "roles", "coder.md")
86
+ if err := os.MkdirAll(filepath.Dir(emptyPath), 0o755); err != nil {
87
+ t.Fatal(err)
88
+ }
89
+ if err := os.WriteFile(emptyPath, nil, 0o644); err != nil {
90
+ t.Fatal(err)
91
+ }
92
+
93
+ directoryRepo := t.TempDir()
94
+ directoryPath := filepath.Join(directoryRepo, ".pi", "roles", "reviewer.md")
95
+ if err := os.MkdirAll(directoryPath, 0o755); err != nil {
96
+ t.Fatal(err)
97
+ }
98
+
99
+ h := newPiHarness(t)
100
+ if err := h.ValidateAgent(context.Background(), emptyRepo, "coder"); err == nil || !strings.Contains(err.Error(), "empty") {
101
+ t.Fatalf("empty role error = %v, want empty-role error", err)
102
+ }
103
+ if err := h.ValidateAgent(context.Background(), directoryRepo, "reviewer"); err == nil || !strings.Contains(err.Error(), "regular file") {
104
+ t.Fatalf("directory role error = %v, want non-regular-role error", err)
105
+ }
106
+ if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
107
+ t.Fatalf("invalid role files invoked Pi lookup: %v", calls)
108
+ }
109
+ }
110
+
111
+ func TestPiValidateAgentRejectsUnsupportedLabelsWithoutAgentDiscovery(t *testing.T) {
112
+ fakeDir := strictPiAvailabilityCLI(t)
113
+ t.Setenv("PATH", fakeDir)
114
+
115
+ h := newPiHarness(t)
116
+ for _, label := range []string{"build", "plan", "gpt-4o", ""} {
117
+ if err := h.ValidateAgent(context.Background(), "/srv/payments", label); err == nil {
118
+ t.Errorf("unsupported agent label %q accepted", label)
119
+ }
120
+ }
121
+ if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
122
+ t.Fatalf("unsupported labels invoked pi instead of rejecting directly: %v", calls)
123
+ }
124
+ }
125
+
126
+ func strictPiAvailabilityCLI(t *testing.T) string {
127
+ t.Helper()
128
+ directory := t.TempDir()
129
+ executable := filepath.Join(directory, "pi")
130
+ calls := filepath.Join(directory, "calls")
131
+ script := `#!/bin/sh
132
+ set -eu
133
+ printf '%s\000' "$*" >> "${PI_FAKE_CALLS:?}"
134
+ case "$#:$*" in
135
+ 1:--version) printf '0.84.1\n' ;;
136
+ *) exit 2 ;;
137
+ esac
138
+ `
139
+ if err := os.WriteFile(executable, []byte(script), 0o700); err != nil {
140
+ t.Fatal(err)
141
+ }
142
+ t.Setenv("PI_FAKE_CALLS", calls)
143
+ return directory
144
+ }
145
+
146
+ func readAvailabilityCalls(t *testing.T, directory string) []string {
147
+ t.Helper()
148
+ data, err := os.ReadFile(filepath.Join(directory, "calls"))
149
+ if os.IsNotExist(err) {
150
+ return nil
151
+ }
152
+ if err != nil {
153
+ t.Fatal(err)
154
+ }
155
+ return strings.Split(strings.TrimSuffix(string(data), "\x00"), "\x00")
156
+ }
157
+
158
+ func writePiRole(t *testing.T, repoPath, name, content string) string {
159
+ t.Helper()
160
+ path := filepath.Join(repoPath, ".pi", "roles", name+".md")
161
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
162
+ t.Fatal(err)
163
+ }
164
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
165
+ t.Fatal(err)
166
+ }
167
+ return path
168
+ }
@@ -6,12 +6,18 @@ import (
6
6
  "crypto/rand"
7
7
  "encoding/hex"
8
8
  "net/url"
9
+ "strconv"
9
10
  "strings"
10
11
  )
11
12
 
12
- // RunID identifies one durable run. Opaque to consumers.
13
+ // RunID identifies one durable execution attempt. Opaque to consumers.
13
14
  type RunID string
14
15
 
16
+ // AttemptID identifies an execution generation for one logical
17
+ // repo/workflow/ticket run. Attempt numbers are durably allocated by the Run
18
+ // Manager and are intentionally simple numeric values.
19
+ type AttemptID uint64
20
+
15
21
  // NodeVisitID identifies one entry into a workflow node. Opaque to consumers.
16
22
  type NodeVisitID string
17
23
 
@@ -25,6 +31,27 @@ func NewRunID(repo, workflow, ticket string) RunID {
25
31
  }, "/"))
26
32
  }
27
33
 
34
+ // NewAttemptRunID derives an execution ID that is fenced from the stable
35
+ // logical run ID. Attempt 1 retains the original deterministic ID for the
36
+ // first execution; explicit restarts use numeric attempt suffixes.
37
+ func NewAttemptRunID(logical RunID, attempt AttemptID) RunID {
38
+ if attempt <= 1 {
39
+ return logical
40
+ }
41
+ return RunID(string(logical) + "~attempt~" + strconv.FormatUint(uint64(attempt), 10))
42
+ }
43
+
44
+ // LogicalRunID returns the stable logical ID embedded in an execution ID.
45
+ // First attempts are already logical IDs; explicit attempts use the
46
+ // `~attempt~<number>` suffix generated by NewAttemptRunID.
47
+ func LogicalRunID(execution RunID) RunID {
48
+ const marker = "~attempt~"
49
+ if index := strings.LastIndex(string(execution), marker); index >= 0 {
50
+ return RunID(string(execution)[:index])
51
+ }
52
+ return execution
53
+ }
54
+
28
55
  // NewNodeVisitID returns a fresh random node-visit ID. Generation happens
29
56
  // once per node entry as a durable replay-safe side effect; this function
30
57
  // only produces the random value.
@@ -0,0 +1,40 @@
1
+ package identity_test
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/rajpopat27/relay-flow/internal/identity"
7
+ )
8
+
9
+ func TestAttemptRunIDUsesNumericAttemptSuffix(t *testing.T) {
10
+ logical := identity.NewRunID("payments", "basicFlow", "PAY-101")
11
+
12
+ if got := identity.NewAttemptRunID(logical, 1); got != logical {
13
+ t.Fatalf("initial attempt ID = %q, want logical ID %q", got, logical)
14
+ }
15
+ if got := identity.NewAttemptRunID(logical, 2); got != logical+"~attempt~2" {
16
+ t.Fatalf("restart attempt ID = %q, want numeric suffix", got)
17
+ }
18
+ if got := identity.NewAttemptRunID(logical, 3); got == identity.NewAttemptRunID(logical, 2) {
19
+ t.Fatalf("attempt IDs are not fenced: attempt 2 and 3 both use %q", got)
20
+ }
21
+ }
22
+
23
+ func TestAttemptIDsAreNumeric(t *testing.T) {
24
+ var first identity.AttemptID = 1
25
+ var second identity.AttemptID = 2
26
+ if first != 1 || second != 2 {
27
+ t.Fatalf("attempt IDs are not numeric: %d, %d", first, second)
28
+ }
29
+ }
30
+
31
+ func TestLogicalRunIDStripsAttemptSuffix(t *testing.T) {
32
+ logical := identity.NewRunID("payments", "basicFlow", "PAY-101")
33
+ execution := identity.NewAttemptRunID(logical, 4)
34
+ if got := identity.LogicalRunID(execution); got != logical {
35
+ t.Fatalf("logical ID = %q, want %q", got, logical)
36
+ }
37
+ if got := identity.LogicalRunID(logical); got != logical {
38
+ t.Fatalf("first-attempt logical ID = %q, want %q", got, logical)
39
+ }
40
+ }
@@ -5,6 +5,7 @@ import (
5
5
  "fmt"
6
6
  "log/slog"
7
7
  "sync"
8
+ "time"
8
9
 
9
10
  "github.com/rajpopat27/relay-flow/internal/identity"
10
11
  "github.com/rajpopat27/relay-flow/internal/repo"
@@ -13,12 +14,15 @@ import (
13
14
  )
14
15
 
15
16
  // CancellationMarker is the stable parent comment marker recording that the
16
- // run was canceled; a missing claimed run carrying it is never recreated.
17
+ // logical run was canceled; a missing claimed run carrying it is never
18
+ // recreated by normal polling.
17
19
  func CancellationMarker(id ID) string {
18
20
  return string(id) + ":cancellation"
19
21
  }
20
22
 
21
- // RunManager performs only assignment and durable-run creation.
23
+ // RunManager performs assignment and durable-run creation. Repos and
24
+ // Workflows are only needed by the explicit restart operation; normal poll
25
+ // handling continues to receive the already-resolved repo/workflow values.
22
26
  type RunManager struct {
23
27
  Executor Executor
24
28
  Runs RunQueries
@@ -28,12 +32,37 @@ type RunManager struct {
28
32
  // a run never starts against a workflow definition that is concurrently
29
33
  // being replaced or removed. A plain *sync.Mutex — no lock service.
30
34
  Gate *sync.Mutex
35
+
36
+ // Repos and Workflows resolve the current task-system repo and latest
37
+ // workflow snapshot for an explicit restart. They are concrete registries,
38
+ // not task/runner/harness-specific dependencies.
39
+ Repos *repo.Registry
40
+ Workflows *workflow.Registry
41
+ }
42
+
43
+ func terminalState(state State) bool {
44
+ return state == StateCompleted || state == StateCanceled
45
+ }
46
+
47
+ func activeState(state State) bool {
48
+ return !terminalState(state) && state != StateCanceling
49
+ }
50
+
51
+ func newerRun(candidate, current Run) bool {
52
+ if candidate.StartedAt.After(current.StartedAt) {
53
+ return true
54
+ }
55
+ if candidate.StartedAt.Equal(current.StartedAt) && candidate.UpdatedAt.After(current.UpdatedAt) {
56
+ return true
57
+ }
58
+ return false
31
59
  }
32
60
 
33
61
  // EnsureRun claims the ticket if unassigned, skips claiming when the ticket
34
- // is already assigned to this workflow, checks the stable cancellation
35
- // marker before recreating a missing claimed run, then ensures the durable
36
- // run with a value snapshot of the workflow.
62
+ // is already assigned to this workflow, reuses the newest active execution
63
+ // attempt, checks the stable logical cancellation marker before recreating a
64
+ // missing claimed run, then ensures the durable run with a value snapshot of
65
+ // the workflow.
37
66
  func (m *RunManager) EnsureRun(ctx context.Context, rp *repo.Repo, wf *workflow.Workflow, ticket task.Ticket) error {
38
67
  if m.Gate != nil {
39
68
  m.Gate.Lock()
@@ -47,19 +76,51 @@ func (m *RunManager) EnsureRun(ctx context.Context, rp *repo.Repo, wf *workflow.
47
76
  break
48
77
  }
49
78
  }
50
- existingRun := false
51
- if claimed && m.Runs != nil {
52
- existing, err := m.Runs.ListRuns(ctx, Filter{Repo: rp.Name, Workflow: wf.Name, Ticket: ticket.Key})
79
+
80
+ var candidates []Run
81
+ if m.Runs != nil {
82
+ var err error
83
+ candidates, err = m.Runs.ListRuns(ctx, Filter{Repo: rp.Name, Workflow: wf.Name, Ticket: ticket.Key})
53
84
  if err != nil {
54
85
  return fmt.Errorf("check existing run %s: %w", id, err)
55
86
  }
56
- for _, candidate := range existing {
57
- if candidate.ID == id {
58
- existingRun = true
59
- break
60
- }
87
+ }
88
+ var latest Run
89
+ latestSet := false
90
+ var active *Run
91
+ for _, candidate := range candidates {
92
+ if candidate.LogicalID == "" {
93
+ candidate.LogicalID = id
94
+ }
95
+ if candidate.AttemptID == 0 {
96
+ candidate.AttemptID = 1
97
+ }
98
+ if !latestSet || newerRun(candidate, latest) {
99
+ latest = candidate
100
+ latestSet = true
101
+ }
102
+ if activeState(candidate.State) && candidate.ID != "" && (active == nil || newerRun(candidate, *active)) {
103
+ copy := candidate
104
+ active = &copy
61
105
  }
62
106
  }
107
+
108
+ // A restarted attempt (including a blocked one) is the current execution
109
+ // and must be ensured by its fenced ID. Do not inspect the cancellation
110
+ // marker for an active attempt.
111
+ if active != nil {
112
+ return m.ensure(ctx, Start{
113
+ ID: active.ID, LogicalID: active.LogicalID, AttemptID: active.AttemptID,
114
+ Repo: rp.Name, RepoPath: rp.Path, Workflow: *wf, Ticket: ticket.Ref(),
115
+ })
116
+ }
117
+
118
+ // Canceling/canceled/completed executions are terminal for normal polling.
119
+ // Only the explicit restart operation may create a new attempt.
120
+ if latestSet && (latest.State == StateCanceling || terminalState(latest.State)) {
121
+ return nil
122
+ }
123
+
63
124
  if !claimed {
64
125
  if err := rp.TaskSystem.Claim(ctx, ticket.Ref(), wf.Name); err != nil {
65
126
  slog.Info("ensure-run outcome",
@@ -67,9 +128,9 @@ func (m *RunManager) EnsureRun(ctx context.Context, rp *repo.Repo, wf *workflow.
67
128
  "outcome", "error", "stage", "claim", "error", err)
68
129
  return fmt.Errorf("claim %s for workflow %s: %w", ticket.Key, wf.Name, err)
69
130
  }
70
- } else if !existingRun {
71
- // Claimed but possibly missing its run (claim-before-run crash gap or
72
- // retention cleanup): never recreate a canceled run.
131
+ } else {
132
+ // Claimed but missing its run (claim-before-run crash gap or retention
133
+ // cleanup): never recreate a canceled logical run.
73
134
  marked, err := rp.TaskSystem.HasComment(ctx, task.Target{Parent: ticket.Ref()}, CancellationMarker(id))
74
135
  if err != nil {
75
136
  slog.Info("ensure-run outcome",
@@ -84,29 +145,136 @@ func (m *RunManager) EnsureRun(ctx context.Context, rp *repo.Repo, wf *workflow.
84
145
  return nil
85
146
  }
86
147
  }
87
- created, err := m.Executor.EnsureRun(ctx, Start{
88
- ID: id,
89
- Repo: rp.Name,
90
- RepoPath: rp.Path,
91
- Workflow: *wf,
92
- Ticket: ticket.Ref(),
148
+ return m.ensure(ctx, Start{
149
+ ID: id, LogicalID: id, AttemptID: 1,
150
+ Repo: rp.Name, RepoPath: rp.Path, Workflow: *wf, Ticket: ticket.Ref(),
93
151
  })
152
+ }
153
+
154
+ func (m *RunManager) ensure(ctx context.Context, start Start) error {
155
+ if m.Executor == nil {
156
+ return fmt.Errorf("ensure run %s: executor is not configured", start.ID)
157
+ }
158
+ created, err := m.Executor.EnsureRun(ctx, start)
94
159
  if err != nil {
95
160
  slog.Info("ensure-run outcome",
96
- "ticket", ticket.Key, "repo", rp.Name, "workflow", wf.Name, "runID", string(id),
161
+ "ticket", start.Ticket.Key, "repo", start.Repo, "workflow", start.Workflow.Name, "runID", string(start.ID),
97
162
  "outcome", "error", "stage", "executor", "error", err)
98
- return fmt.Errorf("ensure run %s: %w", id, err)
163
+ return fmt.Errorf("ensure run %s: %w", start.ID, err)
99
164
  }
100
165
  outcome := "exists"
101
166
  if created {
102
167
  outcome = "created"
103
168
  }
104
169
  slog.Info("ensure-run outcome",
105
- "ticket", ticket.Key, "repo", rp.Name, "workflow", wf.Name, "runID", string(id),
170
+ "ticket", start.Ticket.Key, "repo", start.Repo, "workflow", start.Workflow.Name, "runID", string(start.ID),
106
171
  "outcome", outcome)
107
172
  return nil
108
173
  }
109
174
 
175
+ // RestartByTicket creates or returns the one active fresh attempt for a
176
+ // canceled ticket. It resolves the repo and latest workflow from the current
177
+ // registries, so the new durable snapshot is never copied from the canceled
178
+ // attempt. External task/runner/harness behavior remains behind their normal
179
+ // boundaries and is performed by the durable workflow after creation.
180
+ func (m *RunManager) RestartByTicket(ctx context.Context, ticket string) (Run, error) {
181
+ if m.Gate != nil {
182
+ m.Gate.Lock()
183
+ defer m.Gate.Unlock()
184
+ }
185
+ if m.Runs == nil {
186
+ return Run{}, fmt.Errorf("restart %s: run queries are not configured", ticket)
187
+ }
188
+ previous, err := m.Runs.FindRunByTicket(ctx, ticket)
189
+ if err != nil {
190
+ return Run{}, fmt.Errorf("find run for ticket %s: %w", ticket, err)
191
+ }
192
+
193
+ // Repeating the command while the fresh attempt is active is idempotent.
194
+ // A canceling attempt is deliberately not treated as active: callers must
195
+ // wait for cancellation cleanup to finish before starting another attempt.
196
+ if activeState(previous.State) && previous.AttemptID != 0 {
197
+ return previous, nil
198
+ }
199
+ if previous.State == StateCanceling {
200
+ return Run{}, fmt.Errorf("%w: run %s is still canceling; wait for cancellation to finish", ErrRestartConflict, previous.ID)
201
+ }
202
+ if previous.State != StateCanceled {
203
+ return Run{}, fmt.Errorf("%w: run %s is %s; only canceled runs can be restarted", ErrRestartConflict, previous.ID, previous.State)
204
+ }
205
+ if m.Executor == nil || m.Repos == nil || m.Workflows == nil {
206
+ return Run{}, fmt.Errorf("restart %s: restart dependencies are not configured", ticket)
207
+ }
208
+
209
+ rp, ok := m.Repos.Get(previous.Repo)
210
+ if !ok {
211
+ return Run{}, fmt.Errorf("%w: repo %q for canceled run %s is no longer registered", ErrRestartConflict, previous.Repo, previous.ID)
212
+ }
213
+ wf, ok := m.Workflows.Get(previous.Workflow)
214
+ if !ok {
215
+ return Run{}, fmt.Errorf("%w: workflow %q for canceled run %s is no longer stored", ErrRestartConflict, previous.Workflow, previous.ID)
216
+ }
217
+ bound := false
218
+ for _, name := range wf.Repos {
219
+ if name == previous.Repo {
220
+ bound = true
221
+ break
222
+ }
223
+ }
224
+ if !bound {
225
+ return Run{}, fmt.Errorf("%w: workflow %q no longer targets repo %q", ErrRestartConflict, wf.Name, previous.Repo)
226
+ }
227
+
228
+ logicalID := previous.LogicalID
229
+ if logicalID == "" {
230
+ logicalID = identity.NewRunID(previous.Repo, previous.Workflow, previous.Ticket.Key)
231
+ }
232
+ // The lifecycle gate makes max+1 allocation single-writer within the
233
+ // server. Because every prior attempt is persisted in relay_runs, the
234
+ // number remains stable across process restarts and repeated commands.
235
+ attempts, err := m.Runs.ListRuns(ctx, Filter{Repo: previous.Repo, Workflow: previous.Workflow, Ticket: ticket})
236
+ if err != nil {
237
+ return Run{}, fmt.Errorf("allocate restart attempt for %s: %w", ticket, err)
238
+ }
239
+ var attemptID AttemptID = 1
240
+ for _, candidate := range attempts {
241
+ candidateAttempt := candidate.AttemptID
242
+ if candidateAttempt == 0 {
243
+ candidateAttempt = 1
244
+ }
245
+ if candidateAttempt >= attemptID {
246
+ if candidateAttempt == ^AttemptID(0) {
247
+ return Run{}, fmt.Errorf("%w: attempt number exhausted for %s", ErrRestartConflict, ticket)
248
+ }
249
+ attemptID = candidateAttempt + 1
250
+ }
251
+ }
252
+ executionID := identity.NewAttemptRunID(logicalID, attemptID)
253
+ start := Start{
254
+ ID: executionID, LogicalID: logicalID, AttemptID: attemptID,
255
+ Repo: previous.Repo, RepoPath: rp.Path, Workflow: *wf, Ticket: previous.Ticket,
256
+ }
257
+ if start.Ticket.Key == "" {
258
+ start.Ticket.Key = ticket
259
+ }
260
+ if err := m.ensure(ctx, start); err != nil {
261
+ return Run{}, err
262
+ }
263
+
264
+ // The real projection is inserted before the durable instance is started.
265
+ // A fake or an engine that cannot read it yet still gets a useful command
266
+ // result; a later poll/EnsureRun repairs a missing workflow instance.
267
+ if current, err := m.Runs.GetRun(ctx, executionID); err == nil {
268
+ return current, nil
269
+ }
270
+ now := time.Now().UTC()
271
+ return Run{
272
+ ID: executionID, LogicalID: logicalID, AttemptID: attemptID,
273
+ Repo: start.Repo, Workflow: start.Workflow.Name, Ticket: start.Ticket,
274
+ State: StateStarting, StartedAt: now, UpdatedAt: now,
275
+ }, nil
276
+ }
277
+
110
278
  // CancelByTicket resolves the active run through FindRunByTicket, then
111
279
  // calls Executor.CancelRun.
112
280
  func (m *RunManager) CancelByTicket(ctx context.Context, ticket, reason string) error {
@@ -4,6 +4,7 @@ package run
4
4
 
5
5
  import (
6
6
  "context"
7
+ "errors"
7
8
  "time"
8
9
 
9
10
  "github.com/rajpopat27/relay-flow/internal/config"
@@ -13,6 +14,7 @@ import (
13
14
  )
14
15
 
15
16
  type ID = identity.RunID
17
+ type AttemptID = identity.AttemptID
16
18
  type NodeVisitID = identity.NodeVisitID
17
19
 
18
20
  type State string
@@ -30,12 +32,20 @@ const (
30
32
  // Start carries the immutable value snapshot of the accepted workflow for
31
33
  // deterministic replay. The interpreter consumes only this snapshot.
32
34
  type Start struct {
33
- ID ID `json:"id"`
34
- Repo string `json:"repo"`
35
- RepoPath string `json:"repoPath"`
36
- Workflow workflow.Workflow `json:"workflow"`
37
- Ticket task.TicketRef `json:"ticket"`
38
- Runtime RuntimePolicy `json:"runtime"`
35
+ // ID is the durable execution-attempt ID. The first attempt (attempt 1)
36
+ // uses the deterministic logical ID; explicit restarts use a fenced ID.
37
+ ID ID `json:"id"`
38
+ // LogicalID remains stable across explicit attempts and is used for
39
+ // task-system cancellation fencing and ticket lookup.
40
+ LogicalID ID `json:"logicalRunId,omitempty"`
41
+ // AttemptID is 1 for the original execution and increases for explicit
42
+ // restarts. Zero is accepted only for legacy callers and normalized to 1.
43
+ AttemptID AttemptID `json:"attemptId,omitempty"`
44
+ Repo string `json:"repo"`
45
+ RepoPath string `json:"repoPath"`
46
+ Workflow workflow.Workflow `json:"workflow"`
47
+ Ticket task.TicketRef `json:"ticket"`
48
+ Runtime RuntimePolicy `json:"runtime"`
39
49
  }
40
50
 
41
51
  type RuntimePolicy struct {
@@ -45,6 +55,8 @@ type RuntimePolicy struct {
45
55
 
46
56
  type Work struct {
47
57
  RunID ID
58
+ LogicalID ID
59
+ AttemptID AttemptID
48
60
  Repo string
49
61
  Workflow string
50
62
  Parent task.TicketRef
@@ -81,6 +93,8 @@ type RetryStatus struct {
81
93
 
82
94
  type Run struct {
83
95
  ID ID `json:"id"`
96
+ LogicalID ID `json:"logicalRunId,omitempty"`
97
+ AttemptID AttemptID `json:"attemptId,omitempty"`
84
98
  Repo string `json:"repo"`
85
99
  Workflow string `json:"workflow"`
86
100
  Ticket task.TicketRef `json:"ticket"`
@@ -106,6 +120,10 @@ type ReportAck struct {
106
120
  Duplicate bool `json:"duplicate"`
107
121
  }
108
122
 
123
+ // ErrRestartConflict means the ticket cannot accept an explicit restart in
124
+ // its current durable state. Server handlers map it to HTTP 409.
125
+ var ErrRestartConflict = errors.New("restart conflict")
126
+
109
127
  // NodeRuntimeRegistration binds the OpenCode session emitted for one run/node.
110
128
  type NodeRuntimeRegistration struct {
111
129
  RunID ID `json:"runId"`