relay-flow 0.3.7-alpha → 0.3.9-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 (34) hide show
  1. package/README.md +5 -5
  2. package/cmd/relay-flow/observability_test.go +28 -0
  3. package/cmd/relay-flow/render.go +20 -4
  4. package/cmd/relay-flow/scenario_test.go +15 -13
  5. package/cmd/relay-flow/serve.go +116 -103
  6. package/internal/execution/goworkflows/activities.go +17 -0
  7. package/internal/execution/goworkflows/cancellation_test.go +50 -0
  8. package/internal/execution/goworkflows/engine.go +278 -9
  9. package/internal/execution/goworkflows/fakes_test.go +10 -1
  10. package/internal/execution/goworkflows/interpreter.go +17 -1
  11. package/internal/execution/goworkflows/projection.go +8 -0
  12. package/internal/execution/goworkflows/recovery_test.go +482 -0
  13. package/internal/execution/projection/detail_test.go +38 -0
  14. package/internal/execution/projection/projection.go +80 -15
  15. package/internal/execution/temporal/activities.go +6 -0
  16. package/internal/execution/temporal/recovery.go +4 -0
  17. package/internal/harness/opencode/opencode_test.go +1 -1
  18. package/internal/harness/opencode/repo_setup.go +1 -1
  19. package/internal/recover/recover.go +4 -0
  20. package/internal/repo/binding_test.go +92 -0
  21. package/internal/repo/poller.go +3 -0
  22. package/internal/repo/repo.go +82 -6
  23. package/internal/run/manager.go +41 -0
  24. package/internal/run/run_manager_test.go +56 -0
  25. package/internal/server/observability.go +1 -1
  26. package/internal/task/beads/beads.go +42 -4
  27. package/internal/task/beads/beads_test.go +15 -0
  28. package/internal/task/factory.go +41 -2
  29. package/internal/task/jira/jira.go +52 -28
  30. package/internal/workflow/service.go +53 -0
  31. package/internal/workflow/store.go +413 -19
  32. package/internal/workflow/store_test.go +235 -0
  33. package/internal/workflow/workflow.go +71 -0
  34. package/package.json +1 -1
@@ -257,24 +257,69 @@ func (p *RunProjection) insertStart(ctx context.Context, s run.Start, now time.T
257
257
  }
258
258
 
259
259
  func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.State, lastErr string, finished *time.Time) error {
260
+ _, err := p.updateStateCAS(ctx, id, nil, state, lastErr, finished)
261
+ return err
262
+ }
263
+
264
+ // beginCancellation atomically claims cancellation of a nonterminal run. A
265
+ // run already in canceling keeps its original reason; completed/canceled runs
266
+ // are never moved backwards. The returned row is read after the conditional
267
+ // update so concurrent cancel/complete callers observe the winner.
268
+ func (p *RunProjection) beginCancellation(ctx context.Context, id run.ID, reason string) (run.Run, error) {
269
+ _, err := p.DB.ExecContext(ctx, `
270
+ UPDATE relay_runs SET state = ?, last_error = ?, updated_at = ?
271
+ WHERE id = ? AND state NOT IN ('completed', 'canceled', 'canceling')`,
272
+ string(run.StateCanceling), reason, time.Now().UTC(), string(id))
273
+ if err != nil {
274
+ return run.Run{}, err
275
+ }
276
+ return p.get(ctx, id)
277
+ }
278
+
279
+ func (p *RunProjection) updateStateCAS(ctx context.Context, id run.ID, expected *run.State, state run.State, lastErr string, finished *time.Time) (bool, error) {
260
280
  terminal := state == run.StateCompleted || state == run.StateCanceled
261
281
  now := time.Now().UTC()
262
- _, err := p.DB.ExecContext(ctx, `
282
+ query := `
263
283
  UPDATE relay_runs SET state = ?, last_error = ?, updated_at = ?, finished_at = COALESCE(?, finished_at),
264
284
  retry_error = CASE WHEN ? THEN NULL ELSE retry_error END,
265
285
  retry_attempt = CASE WHEN ? THEN NULL ELSE retry_attempt END,
266
286
  next_retry_at = CASE WHEN ? THEN NULL ELSE next_retry_at END
267
- WHERE id = ?`,
268
- string(state), lastErr, now, finished, terminal, terminal, terminal, string(id))
287
+ WHERE id = ?`
288
+ args := []any{
289
+ string(state), lastErr, now, finished, terminal, terminal, terminal,
290
+ string(id),
291
+ }
292
+ if expected == nil {
293
+ // Ordinary workflow projection updates are fenced after cancellation or
294
+ // any terminal state has won. Explicit compare-and-set callers below
295
+ // are allowed to reconcile a known canceling row to its inspected
296
+ // terminal engine result.
297
+ query += ` AND NOT (
298
+ (state = 'canceling' AND ? NOT IN ('canceling', 'canceled'))
299
+ OR (state IN ('completed', 'canceled') AND state <> ?)
300
+ )`
301
+ args = append(args, string(state), string(state))
302
+ } else {
303
+ query += ` AND state = ? AND NOT (state IN ('completed', 'canceled') AND state <> ?)`
304
+ args = append(args, string(*expected), string(state))
305
+ }
306
+ result, err := p.DB.ExecContext(ctx, query, args...)
269
307
  if err != nil {
270
- return err
308
+ return false, err
309
+ }
310
+ updated, err := result.RowsAffected()
311
+ if err != nil {
312
+ return false, err
313
+ }
314
+ if updated != 1 {
315
+ return false, nil
271
316
  }
272
317
  // Keep the derived timeline useful during retries and cancellation without
273
318
  // making it an execution authority. Identify one active row first; a
274
319
  // repeated terminal update with no active row is a no-op for the timeline.
275
320
  stepStatus := stepStatusForRunState(state)
276
321
  if stepStatus == "" {
277
- return nil
322
+ return true, nil
278
323
  }
279
324
  var sequence int64
280
325
  var startedAt sql.NullTime
@@ -283,14 +328,14 @@ func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.St
283
328
  WHERE run_id = ? AND status IN ('running', 'waiting', 'blocked')
284
329
  ORDER BY sequence DESC LIMIT 1`, string(id)).Scan(&sequence, &startedAt)
285
330
  if errors.Is(lookupErr, sql.ErrNoRows) {
286
- return nil
331
+ return true, nil
287
332
  }
288
333
  if lookupErr != nil {
289
334
  // relay_run_steps is display/cache data. The authoritative relay_runs
290
335
  // state update above has already succeeded and must not be retried just
291
336
  // because this optional projection is unavailable.
292
337
  slog.Warn("step projection state lookup unavailable", "runID", string(id), "state", state, "error", lookupErr)
293
- return nil
338
+ return true, nil
294
339
  }
295
340
  var stepFinished any
296
341
  if terminal {
@@ -300,7 +345,7 @@ func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.St
300
345
  stepFinished = now
301
346
  }
302
347
  }
303
- result, err := p.DB.ExecContext(ctx, `
348
+ result, err = p.DB.ExecContext(ctx, `
304
349
  UPDATE relay_run_steps SET status = ?, message = CASE WHEN ? <> '' THEN ? ELSE message END,
305
350
  finished_at = CASE WHEN ? THEN COALESCE(finished_at, ?) ELSE finished_at END
306
351
  WHERE run_id = ? AND sequence = ? AND status IN ('running', 'waiting', 'blocked')`,
@@ -308,11 +353,11 @@ func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.St
308
353
  string(id), sequence)
309
354
  if err != nil {
310
355
  slog.Warn("step projection state update unavailable", "runID", string(id), "state", state, "error", err)
311
- return nil
356
+ return true, nil
312
357
  }
313
- updated, err := result.RowsAffected()
358
+ updated, err = result.RowsAffected()
314
359
  if err != nil || updated != 1 {
315
- return nil
360
+ return true, nil
316
361
  }
317
362
  if terminal && startedAt.Valid {
318
363
  endAt := now
@@ -327,7 +372,7 @@ func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.St
327
372
  slog.Warn("step projection duration update unavailable", "runID", string(id), "state", state, "error", durationErr)
328
373
  }
329
374
  }
330
- return nil
375
+ return true, nil
331
376
  }
332
377
 
333
378
  func (p *RunProjection) updateRetry(ctx context.Context, id run.ID, status *run.RetryStatus) error {
@@ -352,12 +397,20 @@ func (p *RunProjection) updateNode(ctx context.Context, id run.ID, state run.Sta
352
397
  }
353
398
  defer tx.Rollback()
354
399
  now := time.Now().UTC()
355
- if _, err := tx.ExecContext(ctx, `
400
+ result, err := tx.ExecContext(ctx, `
356
401
  UPDATE relay_runs SET state = ?, current_node = ?, current_node_visit_id = ?, updated_at = ?
357
- WHERE id = ?`,
358
- string(state), node, string(visit), now, string(id)); err != nil {
402
+ WHERE id = ? AND state NOT IN ('canceling', 'completed', 'canceled')`,
403
+ string(state), node, string(visit), now, string(id))
404
+ if err != nil {
359
405
  return err
360
406
  }
407
+ updated, err := result.RowsAffected()
408
+ if err != nil {
409
+ return err
410
+ }
411
+ if updated != 1 {
412
+ return tx.Commit()
413
+ }
361
414
  // A revisit changes only the latest visit ID. Reusable terminal/session
362
415
  // identities remain attached to this run/node row.
363
416
  if _, err := tx.ExecContext(ctx, `
@@ -907,6 +960,18 @@ func (p *RunProjection) UpdateState(ctx context.Context, id run.ID, state run.St
907
960
  return p.updateState(ctx, id, state, lastErr, finished)
908
961
  }
909
962
 
963
+ // BeginCancellation atomically moves a nonterminal run to canceling and
964
+ // returns the persisted row. Existing canceling rows retain their reason.
965
+ func (p *RunProjection) BeginCancellation(ctx context.Context, id run.ID, reason string) (run.Run, error) {
966
+ return p.beginCancellation(ctx, id, reason)
967
+ }
968
+
969
+ // UpdateStateIf applies a lifecycle transition only while the row has the
970
+ // expected state. It returns false when another transition won the race.
971
+ func (p *RunProjection) UpdateStateIf(ctx context.Context, id run.ID, expected, state run.State, lastErr string, finished *time.Time) (bool, error) {
972
+ return p.updateStateCAS(ctx, id, &expected, state, lastErr, finished)
973
+ }
974
+
910
975
  // UpdateRetry updates or clears active retry metadata.
911
976
  func (p *RunProjection) UpdateRetry(ctx context.Context, id run.ID, status *run.RetryStatus) error {
912
977
  return p.updateRetry(ctx, id, status)
@@ -37,6 +37,12 @@ func (a *Activities) taskSystem(repoName string) (task.System, error) {
37
37
  if !ok {
38
38
  return nil, fmt.Errorf("repo %q is not registered", repoName)
39
39
  }
40
+ if rp.TaskSystem == nil {
41
+ if rp.TaskSystemError != nil {
42
+ return nil, fmt.Errorf("repo %q task system unavailable: %w", repoName, rp.TaskSystemError)
43
+ }
44
+ return nil, fmt.Errorf("repo %q task system unavailable", repoName)
45
+ }
40
46
  return rp.TaskSystem, nil
41
47
  }
42
48
 
@@ -141,6 +141,10 @@ func listWorkflowExecutions(ctx context.Context, list func(context.Context, *wor
141
141
  // ID is described and restored only when Temporal already owns that execution.
142
142
  func (e *Engine) reconcileClaimedParents(ctx context.Context, visible map[string]bool) error {
143
143
  for _, rp := range e.deps.Repos.List() {
144
+ if rp.TaskSystem == nil {
145
+ slog.Warn("Temporal recovery skipping repository with unavailable task system", "repo", rp.Name, "error", rp.TaskSystemError)
146
+ continue
147
+ }
144
148
  tickets, err := rp.TaskSystem.Poll(ctx)
145
149
  if err != nil {
146
150
  return fmt.Errorf("poll repo %q during Temporal recovery: %w", rp.Name, err)
@@ -14,7 +14,7 @@ import (
14
14
  "github.com/rajpopat27/relay-flow/internal/workflow"
15
15
  )
16
16
 
17
- const configuredPlugin = "relay-flow-plugin@0.3.7-alpha"
17
+ const configuredPlugin = "relay-flow-plugin@0.3.9-alpha"
18
18
 
19
19
  func TestBuildCommandArgv(t *testing.T) {
20
20
  t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
@@ -10,7 +10,7 @@ import (
10
10
  "github.com/rajpopat27/relay-flow/internal/config"
11
11
  )
12
12
 
13
- const relayFlowPlugin = "relay-flow-plugin@0.3.7-alpha"
13
+ const relayFlowPlugin = "relay-flow-plugin@0.3.9-alpha"
14
14
 
15
15
  type jsoncToken struct {
16
16
  kind byte
@@ -46,6 +46,10 @@ type MailboxSpecFor func(task.System, run.Work, *workflow.Workflow) ([]task.Mail
46
46
  // never automatic; database loss is never inferred.
47
47
  func FromTaskSystem(ctx context.Context, repoReg *repo.Registry, rnr runner.Runner, runManager *run.RunManager, specsFor MailboxSpecFor) error {
48
48
  for _, rp := range repoReg.List() {
49
+ if rp.TaskSystem == nil {
50
+ slog.Warn("recover: skip repository with unavailable task system", "repo", rp.Name, "error", rp.TaskSystemError)
51
+ continue
52
+ }
49
53
  tickets, err := rp.TaskSystem.Poll(ctx)
50
54
  if err != nil {
51
55
  return fmt.Errorf("repo %q poll: %w", rp.Name, err)
@@ -0,0 +1,92 @@
1
+ package repo
2
+
3
+ import (
4
+ "errors"
5
+ "strings"
6
+ "testing"
7
+
8
+ "github.com/rajpopat27/relay-flow/internal/config"
9
+ "github.com/rajpopat27/relay-flow/internal/task"
10
+ "github.com/rajpopat27/relay-flow/internal/workflow"
11
+ )
12
+
13
+ type bindingTaskSystem struct {
14
+ task.System
15
+ compileErr map[string]error
16
+ }
17
+
18
+ func (s bindingTaskSystem) CompileFilter(values config.RawValues) (func(task.Ticket) bool, error) {
19
+ if name, ok := values["name"].(string); ok {
20
+ if err := s.compileErr[name]; err != nil {
21
+ return nil, err
22
+ }
23
+ return func(ticket task.Ticket) bool { return ticket.Key == name }, nil
24
+ }
25
+ return func(task.Ticket) bool { return true }, nil
26
+ }
27
+
28
+ func TestBindWorkflowsIsolatedPublishesHealthyWorkflows(t *testing.T) {
29
+ system := bindingTaskSystem{compileErr: map[string]error{"bad": errors.New("invalid filter")}}
30
+ registered := &Repo{Name: "payments", TaskSystem: system}
31
+ registry := NewRegistry()
32
+ registry.Replace(registered)
33
+
34
+ bad := &workflow.Workflow{Name: "badFlow", Repos: []string{"payments"}, Status: workflow.HealthHealthy, TaskConfig: config.RawValues{"name": "bad"}}
35
+ good := &workflow.Workflow{Name: "goodFlow", Repos: []string{"payments"}, Status: workflow.HealthHealthy, TaskConfig: config.RawValues{"name": "good"}}
36
+ issues := registry.BindWorkflowsIsolated([]*workflow.Workflow{bad, good})
37
+ if len(issues) != 1 || issues[0].Workflow != bad {
38
+ t.Fatalf("binding issues = %#v, want only badFlow", issues)
39
+ }
40
+ if bad.Status != workflow.HealthBlocked || bad.StatusReason == "" {
41
+ t.Fatalf("bad workflow status = %q reason=%q", bad.Status, bad.StatusReason)
42
+ }
43
+ bindings := registered.Bindings()
44
+ if len(bindings) != 1 || bindings[0].Workflow != good {
45
+ t.Fatalf("published bindings = %#v, want only goodFlow", bindings)
46
+ }
47
+ }
48
+
49
+ func TestBindWorkflowsIsolatedSkipsOutdatedWorkflow(t *testing.T) {
50
+ registered := &Repo{Name: "payments", TaskSystem: bindingTaskSystem{}}
51
+ registry := NewRegistry()
52
+ registry.Replace(registered)
53
+ wf := &workflow.Workflow{Name: "editedFlow", Repos: []string{"payments"}, Status: workflow.HealthOutdated}
54
+ if issues := registry.BindWorkflowsIsolated([]*workflow.Workflow{wf}); len(issues) != 0 {
55
+ t.Fatalf("outdated workflow binding issues = %#v, want none", issues)
56
+ }
57
+ if bindings := registered.Bindings(); len(bindings) != 0 {
58
+ t.Fatalf("outdated workflow bindings = %#v, want none", bindings)
59
+ }
60
+ }
61
+
62
+ func TestBindWorkflowsIsolatedBlocksUnavailableTaskSystem(t *testing.T) {
63
+ registered := &Repo{Name: "payments", TaskSystemError: errors.New("task service offline")}
64
+ registry := NewRegistry()
65
+ registry.Replace(registered)
66
+ wf := &workflow.Workflow{Name: "paymentsFlow", Repos: []string{"payments"}, Status: workflow.HealthHealthy}
67
+ issues := registry.BindWorkflowsIsolated([]*workflow.Workflow{wf})
68
+ if len(issues) != 1 || wf.Status != workflow.HealthBlocked || !strings.Contains(wf.StatusReason, "task service offline") {
69
+ t.Fatalf("unavailable task system issues=%#v status=%q reason=%q", issues, wf.Status, wf.StatusReason)
70
+ }
71
+ if bindings := registered.Bindings(); len(bindings) != 0 {
72
+ t.Fatalf("unavailable repo bindings = %#v, want none", bindings)
73
+ }
74
+ }
75
+
76
+ func TestBindWorkflowsIsolatedClearsStaleBindings(t *testing.T) {
77
+ registered := &Repo{Name: "payments", TaskSystem: bindingTaskSystem{}}
78
+ registry := NewRegistry()
79
+ registry.Replace(registered)
80
+ first := &workflow.Workflow{Name: "firstFlow", Repos: []string{"payments"}, Status: workflow.HealthHealthy}
81
+ if issues := registry.BindWorkflowsIsolated([]*workflow.Workflow{first}); len(issues) != 0 {
82
+ t.Fatalf("initial binding issues = %#v", issues)
83
+ }
84
+ second := &workflow.Workflow{Name: "secondFlow", Repos: []string{"payments"}, Status: workflow.HealthHealthy}
85
+ if issues := registry.BindWorkflowsIsolated([]*workflow.Workflow{second}); len(issues) != 0 {
86
+ t.Fatalf("replacement binding issues = %#v", issues)
87
+ }
88
+ bindings := registered.Bindings()
89
+ if len(bindings) != 1 || bindings[0].Workflow != second {
90
+ t.Fatalf("replacement bindings = %#v, want only secondFlow", bindings)
91
+ }
92
+ }
@@ -39,6 +39,9 @@ func (p *RepoPoller) Run(ctx context.Context) {
39
39
  }
40
40
 
41
41
  func (p *RepoPoller) poll(ctx context.Context) {
42
+ if p.Repo == nil || p.Repo.TaskSystem == nil {
43
+ return
44
+ }
42
45
  if p.sem != nil {
43
46
  select {
44
47
  case p.sem <- struct{}{}:
@@ -31,8 +31,12 @@ type Repo struct {
31
31
  Path string
32
32
  TaskConfig config.RawValues
33
33
  TaskSystem task.System
34
- Workflows []WorkflowBinding
35
- bindingsMu sync.RWMutex
34
+ // TaskSystemError is set when local startup construction could not create
35
+ // a usable adapter. The repo remains visible for diagnostics and repair,
36
+ // while only workflows referencing it are isolated.
37
+ TaskSystemError error
38
+ Workflows []WorkflowBinding
39
+ bindingsMu sync.RWMutex
36
40
  }
37
41
 
38
42
  func (r *Repo) Info() Info {
@@ -95,25 +99,97 @@ func (r *Registry) Remove(name string) {
95
99
  // BindWorkflows rebuilds the derived Repo.Workflows index from the given
96
100
  // workflows. Each repo that a workflow lists gets a binding with the
97
101
  // matcher compiled by that repo's task system. Repos not listed by a
98
- // workflow keep no binding for it.
102
+ // workflow keep no binding for it. The strict form is used by submission,
103
+ // where a binding error must reject the candidate before it is stored.
99
104
  func (r *Registry) BindWorkflows(workflows []*workflow.Workflow) error {
105
+ _, err := r.bindWorkflows(workflows, false)
106
+ return err
107
+ }
108
+
109
+ // BindingIssue identifies a workflow that could not be safely published.
110
+ // Startup uses the isolated form so one invalid workflow does not prevent
111
+ // unrelated workflows from being bound.
112
+ type BindingIssue struct {
113
+ Workflow *workflow.Workflow
114
+ Error error
115
+ }
116
+
117
+ // BindWorkflowsIsolated rebuilds all bindings while isolating workflows whose
118
+ // referenced repo or matcher cannot be built. The returned issues are
119
+ // diagnostics only; valid workflows are still published atomically.
120
+ func (r *Registry) BindWorkflowsIsolated(workflows []*workflow.Workflow) []BindingIssue {
121
+ issues, _ := r.bindWorkflows(workflows, true)
122
+ return issues
123
+ }
124
+
125
+ func (r *Registry) bindWorkflows(workflows []*workflow.Workflow, isolate bool) ([]BindingIssue, error) {
100
126
  type binding struct {
101
127
  wf *workflow.Workflow
102
128
  match func(task.Ticket) bool
103
129
  }
104
130
  byRepo := map[string][]binding{}
131
+ issues := []BindingIssue{}
105
132
  for _, wf := range workflows {
133
+ if wf == nil || !wf.IsRoutable() {
134
+ continue
135
+ }
136
+ failed := false
106
137
  for _, repoName := range wf.Repos {
107
138
  rp, ok := r.Get(repoName)
108
139
  if !ok {
109
- return fmt.Errorf("workflow %q references unregistered repo %q", wf.Name, repoName)
140
+ err := fmt.Errorf("workflow %q references unregistered repo %q", wf.Name, repoName)
141
+ if !isolate {
142
+ return nil, err
143
+ }
144
+ wf.MarkBlocked(err.Error(), wf.RepairCommand)
145
+ issues = append(issues, BindingIssue{Workflow: wf, Error: err})
146
+ failed = true
147
+ break
148
+ }
149
+ if rp.TaskSystem == nil {
150
+ reason := rp.TaskSystemError
151
+ if reason == nil {
152
+ reason = fmt.Errorf("repo %q task system is unavailable", repoName)
153
+ }
154
+ err := fmt.Errorf("workflow %q repo %q: task system unavailable: %w", wf.Name, repoName, reason)
155
+ if !isolate {
156
+ return nil, err
157
+ }
158
+ wf.MarkBlocked(err.Error(), wf.RepairCommand)
159
+ issues = append(issues, BindingIssue{Workflow: wf, Error: err})
160
+ failed = true
161
+ break
110
162
  }
111
163
  match, err := rp.TaskSystem.CompileFilter(wf.TaskConfig)
112
164
  if err != nil {
113
- return fmt.Errorf("workflow %q repo %q: compile filter: %w", wf.Name, repoName, err)
165
+ err = fmt.Errorf("workflow %q repo %q: compile filter: %w", wf.Name, repoName, err)
166
+ if !isolate {
167
+ return nil, err
168
+ }
169
+ wf.MarkBlocked(err.Error(), wf.RepairCommand)
170
+ issues = append(issues, BindingIssue{Workflow: wf, Error: err})
171
+ failed = true
172
+ break
114
173
  }
115
174
  byRepo[repoName] = append(byRepo[repoName], binding{wf: wf, match: match})
116
175
  }
176
+ if failed {
177
+ // A workflow is all-or-nothing across its referenced repositories;
178
+ // never leave a partial route for it.
179
+ for repoName, binds := range byRepo {
180
+ filtered := binds[:0]
181
+ for _, b := range binds {
182
+ if b.wf != wf {
183
+ filtered = append(filtered, b)
184
+ }
185
+ }
186
+ if len(filtered) == 0 {
187
+ delete(byRepo, repoName)
188
+ } else {
189
+ byRepo[repoName] = filtered
190
+ }
191
+ }
192
+ }
117
193
  }
118
194
  r.mu.Lock()
119
195
  defer r.mu.Unlock()
@@ -128,5 +204,5 @@ func (r *Registry) BindWorkflows(workflows []*workflow.Workflow) error {
128
204
  rp.Workflows = next
129
205
  rp.bindingsMu.Unlock()
130
206
  }
131
- return nil
207
+ return issues, nil
132
208
  }
@@ -64,10 +64,48 @@ func newerRun(candidate, current Run) bool {
64
64
  // missing claimed run, then ensures the durable run with a value snapshot of
65
65
  // the workflow.
66
66
  func (m *RunManager) EnsureRun(ctx context.Context, rp *repo.Repo, wf *workflow.Workflow, ticket task.Ticket) error {
67
+ if rp == nil {
68
+ return fmt.Errorf("ensure run: repository is unavailable")
69
+ }
70
+ if rp.TaskSystem == nil {
71
+ if rp.TaskSystemError != nil {
72
+ return fmt.Errorf("ensure run repo %q: task system unavailable: %w", rp.Name, rp.TaskSystemError)
73
+ }
74
+ return fmt.Errorf("ensure run repo %q: task system unavailable", rp.Name)
75
+ }
76
+ if wf == nil {
77
+ return fmt.Errorf("ensure run repo %q: workflow is unavailable", rp.Name)
78
+ }
67
79
  if m.Gate != nil {
68
80
  m.Gate.Lock()
69
81
  defer m.Gate.Unlock()
70
82
  }
83
+ // Poll routing resolves a binding before entering this method. Resolve
84
+ // the registry again while holding the same lifecycle gate used by submit
85
+ // and remove so a concurrent replacement cannot create a run from the old
86
+ // workflow snapshot.
87
+ if m.Workflows != nil {
88
+ current, ok := m.Workflows.Get(wf.Name)
89
+ if !ok {
90
+ return fmt.Errorf("ensure run repo %q: workflow %q is no longer stored", rp.Name, wf.Name)
91
+ }
92
+ wf = current
93
+ }
94
+ if !wf.IsRoutable() {
95
+ return fmt.Errorf("ensure run repo %q: workflow %q is %s and cannot start a new run", rp.Name, wf.Name, wf.Status)
96
+ }
97
+ if m.Workflows != nil {
98
+ targeted := false
99
+ for _, repoName := range wf.Repos {
100
+ if repoName == rp.Name {
101
+ targeted = true
102
+ break
103
+ }
104
+ }
105
+ if !targeted {
106
+ return fmt.Errorf("ensure run repo %q: workflow %q no longer targets this repository", rp.Name, wf.Name)
107
+ }
108
+ }
71
109
  id := identity.NewRunID(rp.Name, wf.Name, ticket.Key)
72
110
  claimed := false
73
111
  for _, c := range ticket.WorkflowClaims {
@@ -214,6 +252,9 @@ func (m *RunManager) RestartByTicket(ctx context.Context, ticket string) (Run, e
214
252
  if !ok {
215
253
  return Run{}, fmt.Errorf("%w: workflow %q for canceled run %s is no longer stored", ErrRestartConflict, previous.Workflow, previous.ID)
216
254
  }
255
+ if !wf.IsRoutable() {
256
+ return Run{}, fmt.Errorf("%w: workflow %q is %s and cannot be restarted", ErrRestartConflict, wf.Name, wf.Status)
257
+ }
217
258
  bound := false
218
259
  for _, name := range wf.Repos {
219
260
  if name == previous.Repo {
@@ -259,6 +259,62 @@ func TestDeterministicRunID(t *testing.T) {
259
259
  }
260
260
  }
261
261
 
262
+ func TestEnsureRunReResolvesWorkflowUnderLifecycleRegistry(t *testing.T) {
263
+ log := newEventLog()
264
+ sys := &recordingSystem{log: log}
265
+ exec := &fakeExecutor{log: log}
266
+ old := testWorkflow("basicFlow")
267
+ latest := testWorkflow("basicFlow")
268
+ latest.Status = workflow.HealthOutdated
269
+ workflows := &workflow.Registry{}
270
+ workflows.Replace(latest)
271
+ m := &run.RunManager{Executor: exec, Runs: &fakeQueries{}, Workflows: workflows}
272
+ if err := m.EnsureRun(context.Background(), testRepo(sys), old, task.Ticket{Key: "PAY-101"}); err == nil {
273
+ t.Fatal("EnsureRun used stale routable workflow despite registry replacement")
274
+ }
275
+ if len(exec.ensures) != 0 || len(log.all()) != 0 {
276
+ t.Fatalf("stale workflow caused external work: ensures=%d events=%v", len(exec.ensures), log.all())
277
+ }
278
+ }
279
+
280
+ func TestEnsureRunRejectsOutdatedWorkflow(t *testing.T) {
281
+ log := newEventLog()
282
+ sys := &recordingSystem{log: log}
283
+ exec := &fakeExecutor{log: log}
284
+ wf := testWorkflow("basicFlow")
285
+ wf.Status = workflow.HealthOutdated
286
+ m := &run.RunManager{Executor: exec, Runs: &fakeQueries{}}
287
+ if err := m.EnsureRun(context.Background(), testRepo(sys), wf, task.Ticket{Key: "PAY-101"}); err == nil {
288
+ t.Fatal("EnsureRun accepted an outdated workflow")
289
+ }
290
+ if len(exec.ensures) != 0 || len(log.all()) != 0 {
291
+ t.Fatalf("outdated workflow caused external work: ensures=%d events=%v", len(exec.ensures), log.all())
292
+ }
293
+ }
294
+
295
+ func TestRestartByTicketRejectsOutdatedWorkflow(t *testing.T) {
296
+ log := newEventLog()
297
+ sys := &recordingSystem{log: log}
298
+ exec := &fakeExecutor{log: log}
299
+ wf := testWorkflow("basicFlow")
300
+ wf.Status = workflow.HealthOutdated
301
+ repos := repo.NewRegistry()
302
+ repos.Replace(testRepo(sys))
303
+ workflows := &workflow.Registry{}
304
+ workflows.Replace(wf)
305
+ m := &run.RunManager{
306
+ Executor: exec,
307
+ Runs: &fakeQueries{byTicket: map[string]run.Run{"PAY-101": {ID: "old", Repo: "payments", Workflow: "basicFlow", Ticket: task.TicketRef{Key: "PAY-101"}, State: run.StateCanceled}}},
308
+ Repos: repos, Workflows: workflows,
309
+ }
310
+ if _, err := m.RestartByTicket(context.Background(), "PAY-101"); err == nil {
311
+ t.Fatal("RestartByTicket accepted an outdated workflow")
312
+ }
313
+ if len(exec.ensures) != 0 {
314
+ t.Fatalf("outdated restart ensured %d runs", len(exec.ensures))
315
+ }
316
+ }
317
+
262
318
  func TestRestartByTicketCreatesNumericFreshAttempt(t *testing.T) {
263
319
  log := newEventLog()
264
320
  sys := &recordingSystem{log: log}
@@ -105,5 +105,5 @@ func BuildWorkflowDetail(wf *workflow.Workflow, runs []run.Run) WorkflowDetail {
105
105
  active++
106
106
  }
107
107
  }
108
- return WorkflowDetail{Workflow: wf, Valid: wf != nil, ActiveRuns: active, RecentRuns: recent}
108
+ return WorkflowDetail{Workflow: wf, Valid: wf != nil && wf.IsRoutable(), ActiveRuns: active, RecentRuns: recent}
109
109
  }
@@ -105,6 +105,7 @@ func init() {
105
105
  DefaultConfig: DefaultConfig,
106
106
  ValidateTextConfig: validateTextConfig,
107
107
  New: newSystem,
108
+ NewLocal: newSystemLocal,
108
109
  })
109
110
  }
110
111
 
@@ -184,6 +185,14 @@ func (s *system) AgentEnv() map[string]string {
184
185
  // workspace must be supplied by repoConfig; a root-level value never satisfies
185
186
  // the required repo-scoped key.
186
187
  func beadsTaskScopeKey(rootConfig, repoConfig config.RawValues) (string, error) {
188
+ beadsDir, err := configuredBeadsDir(rootConfig, repoConfig)
189
+ if err != nil {
190
+ return "", err
191
+ }
192
+ return canonicalBeadsDir(beadsDir)
193
+ }
194
+
195
+ func configuredBeadsDir(rootConfig, repoConfig config.RawValues) (string, error) {
187
196
  var root Config
188
197
  if err := config.DecodeStrict(rootConfig, &root); err != nil {
189
198
  return "", fmt.Errorf("root task config: %w", err)
@@ -195,7 +204,7 @@ func beadsTaskScopeKey(rootConfig, repoConfig config.RawValues) (string, error)
195
204
  if strings.TrimSpace(repo.BeadsDir) == "" {
196
205
  return "", errors.New("beads task scope requires repo beadsDir")
197
206
  }
198
- return canonicalBeadsDir(repo.BeadsDir)
207
+ return repo.BeadsDir, nil
199
208
  }
200
209
 
201
210
  func canonicalBeadsDir(value string) (string, error) {
@@ -221,9 +230,31 @@ func canonicalBeadsDir(value string) (string, error) {
221
230
  return filepath.Clean(resolved), nil
222
231
  }
223
232
 
233
+ func localBeadsDir(rootConfig, repoConfig config.RawValues) (string, error) {
234
+ configured, err := configuredBeadsDir(rootConfig, repoConfig)
235
+ if err != nil {
236
+ return "", err
237
+ }
238
+ abs, err := filepath.Abs(strings.TrimSpace(configured))
239
+ if err != nil {
240
+ return "", fmt.Errorf("resolve beadsDir %q: %w", configured, err)
241
+ }
242
+ return filepath.Clean(abs), nil
243
+ }
244
+
224
245
  // newSystem constructs and probes a repo-bound Beads task system. It does not
225
246
  // initialize a workspace or start any Beads/Dolt server.
226
247
  func newSystem(ctx context.Context, spec task.RepoSpec) (task.System, error) {
248
+ return newSystemWithProbe(ctx, spec, true)
249
+ }
250
+
251
+ // newSystemLocal constructs only local Beads state. Probe is intentionally
252
+ // deferred to workflow submission so restart can still expose management APIs.
253
+ func newSystemLocal(ctx context.Context, spec task.RepoSpec) (task.System, error) {
254
+ return newSystemWithProbe(ctx, spec, false)
255
+ }
256
+
257
+ func newSystemWithProbe(ctx context.Context, spec task.RepoSpec, probe bool) (task.System, error) {
227
258
  if strings.TrimSpace(spec.Name) == "" {
228
259
  return nil, errors.New("beads: repo name is required")
229
260
  }
@@ -233,7 +264,12 @@ func newSystem(ctx context.Context, spec task.RepoSpec) (task.System, error) {
233
264
  }
234
265
  // Validate the repo-scoped key before merging with root values. This keeps a
235
266
  // root beadsDir from silently satisfying repository registration.
236
- beadsDir, err := beadsTaskScopeKey(spec.RootConfig, spec.RepoConfig)
267
+ var beadsDir string
268
+ if probe {
269
+ beadsDir, err = beadsTaskScopeKey(spec.RootConfig, spec.RepoConfig)
270
+ } else {
271
+ beadsDir, err = localBeadsDir(spec.RootConfig, spec.RepoConfig)
272
+ }
237
273
  if err != nil {
238
274
  return nil, fmt.Errorf("beads repo %q: %w", spec.Name, err)
239
275
  }
@@ -246,8 +282,10 @@ func newSystem(ctx context.Context, spec task.RepoSpec) (task.System, error) {
246
282
  return nil, fmt.Errorf("beads repo %q config: %w", spec.Name, err)
247
283
  }
248
284
  cli := bdcli.New(spec.Path, beadsDir)
249
- if err := cli.Probe(ctx); err != nil {
250
- return nil, fmt.Errorf("beads repo %q probe: %w", spec.Name, err)
285
+ if probe {
286
+ if err := cli.Probe(ctx); err != nil {
287
+ return nil, fmt.Errorf("beads repo %q probe: %w", spec.Name, err)
288
+ }
251
289
  }
252
290
  return &system{
253
291
  cli: cli,