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.
- package/README.md +154 -13
- package/cmd/relay-flow/commands_test.go +5 -1
- package/cmd/relay-flow/main.go +27 -1
- package/cmd/relay-flow/pi_wiring_test.go +64 -0
- package/cmd/relay-flow/serve.go +14 -0
- package/examples/beads-workflow.yaml +3 -3
- package/examples/config-reference.yaml +144 -0
- package/examples/minimal-beads-task-workflow.yaml +34 -0
- package/examples/minimal-jira-task-workflow.yaml +68 -0
- package/examples/workflow-reference.yaml +111 -0
- package/internal/execution/goworkflows/activities.go +19 -0
- package/internal/execution/goworkflows/engine.go +20 -0
- package/internal/execution/goworkflows/engine_test.go +1 -1
- package/internal/execution/goworkflows/fakes_test.go +34 -6
- package/internal/execution/goworkflows/interpreter.go +48 -1
- package/internal/execution/goworkflows/projection.go +52 -11
- package/internal/execution/goworkflows/recovery_test.go +129 -0
- package/internal/harness/opencode/opencode.go +4 -4
- package/internal/harness/opencode/opencode_test.go +59 -4
- package/internal/harness/opencode/repo_setup.go +42 -2
- package/internal/harness/pi/config_test.go +46 -0
- package/internal/harness/pi/lifecycle_test.go +69 -0
- package/internal/harness/pi/pi.go +261 -0
- package/internal/harness/pi/pi_test.go +322 -0
- package/internal/harness/pi/prompt_test.go +121 -0
- package/internal/harness/pi/testdata/pi-0.84.1/capture.json +126 -0
- package/internal/harness/pi/testdata/pi-0.84.1/noninteractive-output.txt +11 -0
- package/internal/harness/pi/testdata/pi-0.84.1/tui-output-sanitized.txt +17 -0
- package/internal/harness/pi/validation_test.go +168 -0
- package/internal/identity/identity.go +28 -1
- package/internal/identity/identity_test.go +40 -0
- package/internal/run/manager.go +193 -25
- package/internal/run/run.go +24 -6
- package/internal/run/run_manager_test.go +100 -0
- package/internal/runner/herdr/baseref.go +60 -0
- package/internal/runner/herdr/herdr.go +578 -0
- package/internal/runner/herdr/herdr_test.go +688 -0
- package/internal/runner/herdr/herdrcli/contract.go +128 -0
- package/internal/runner/herdr/herdrcli/exec.go +68 -0
- package/internal/runner/herdr/herdrcli/herdrcli_test.go +274 -0
- package/internal/runner/herdr/herdrcli/live_test.go +132 -0
- package/internal/runner/herdr/herdrcli/operations.go +281 -0
- package/internal/runner/herdr/herdrcli/response.go +116 -0
- package/internal/runner/herdr/herdrcli/testdata/empty-panes.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/empty-tabs.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-not-git-worktree.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-pane-not-found.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-workspace-not-found.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-worktree-not-found.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/malformed.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-close.json +6 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-get.json +25 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-list.json +45 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-process-info-shell.json +22 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-process-info.json +23 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-rename.json +23 -0
- package/internal/runner/herdr/herdrcli/testdata/snapshot.json +213 -0
- package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +175 -0
- package/internal/runner/herdr/herdrcli/testdata/tab-create.json +31 -0
- package/internal/runner/herdr/herdrcli/testdata/tab-list.json +26 -0
- package/internal/runner/herdr/herdrcli/testdata/workspace-close.json +6 -0
- package/internal/runner/herdr/herdrcli/testdata/worktree-create.json +58 -0
- package/internal/runner/herdr/herdrcli/testdata/worktree-list.json +35 -0
- package/internal/runner/herdr/herdrcli/testdata/worktree-open.json +59 -0
- package/internal/server/api_test.go +54 -0
- package/internal/server/client.go +11 -0
- package/internal/server/fixture_test.go +18 -0
- package/internal/server/server.go +16 -0
- package/internal/task/beads/beads.go +16 -16
- package/internal/task/beads/config_compatibility_test.go +7 -8
- package/internal/task/beads/status_compatibility_test.go +43 -0
- package/internal/task/jira/filters_test.go +32 -6
- package/internal/task/jira/helpers_test.go +3 -0
- package/internal/task/jira/jira.go +43 -17
- package/internal/task/jira/transition_defaults_test.go +43 -0
- package/internal/task/task.go +8 -0
- 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
|
|
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
|
+
}
|
package/internal/run/manager.go
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
|
35
|
-
//
|
|
36
|
-
// run with a value snapshot of
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 = ©
|
|
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
|
|
71
|
-
// Claimed but
|
|
72
|
-
//
|
|
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
|
-
|
|
88
|
-
ID:
|
|
89
|
-
Repo:
|
|
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",
|
|
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",
|
|
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",
|
|
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 {
|
package/internal/run/run.go
CHANGED
|
@@ -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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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"`
|