relay-flow 0.2.0-alpha → 0.2.1-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 (55) hide show
  1. package/README.md +18 -6
  2. package/cmd/relay-flow/commands_test.go +519 -15
  3. package/cmd/relay-flow/main.go +331 -119
  4. package/cmd/relay-flow/scenario_test.go +209 -34
  5. package/cmd/relay-flow/serve.go +1 -0
  6. package/examples/default-story-workflow.yaml +88 -0
  7. package/internal/execution/goworkflows/activities.go +65 -65
  8. package/internal/execution/goworkflows/engine.go +41 -8
  9. package/internal/execution/goworkflows/engine_test.go +73 -13
  10. package/internal/execution/goworkflows/fakes_test.go +13 -21
  11. package/internal/execution/goworkflows/interpreter.go +16 -8
  12. package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
  13. package/internal/execution/goworkflows/node_runtime_test.go +45 -21
  14. package/internal/execution/goworkflows/recovery_test.go +5 -5
  15. package/internal/execution/goworkflows/retry_log_test.go +11 -11
  16. package/internal/harness/contract_test.go +5 -0
  17. package/internal/paths/paths.go +18 -16
  18. package/internal/repo/repo.go +13 -0
  19. package/internal/repo/service_test.go +4 -4
  20. package/internal/router/router.go +3 -2
  21. package/internal/router/router_test.go +87 -0
  22. package/internal/run/manager.go +14 -1
  23. package/internal/run/run_manager_test.go +21 -1
  24. package/internal/runner/contract_test.go +64 -26
  25. package/internal/runner/orca/orca.go +30 -54
  26. package/internal/runner/orca/orca_test.go +143 -4
  27. package/internal/runner/orca/orcacli/orcacli.go +5 -0
  28. package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
  29. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
  30. package/internal/runner/runner.go +15 -8
  31. package/internal/task/auth_test.go +48 -0
  32. package/internal/task/contract_test.go +2 -0
  33. package/internal/task/factory.go +16 -0
  34. package/internal/task/jira/auth.go +183 -0
  35. package/internal/task/jira/auth_test.go +107 -0
  36. package/internal/task/jira/effects_test.go +39 -0
  37. package/internal/task/jira/filters_test.go +36 -16
  38. package/internal/task/jira/helpers_test.go +29 -19
  39. package/internal/task/jira/jira.go +92 -61
  40. package/internal/task/jira/normalize.go +32 -14
  41. package/internal/task/jira/rest/adf.go +128 -0
  42. package/internal/task/jira/rest/client.go +573 -0
  43. package/internal/task/jira/rest/client_test.go +381 -0
  44. package/internal/task/jira/transition_defaults_test.go +18 -16
  45. package/internal/task/jira/validation_test.go +1 -1
  46. package/internal/workflow/workflow.go +9 -6
  47. package/internal/workflow/workflow_test.go +14 -12
  48. package/package.json +2 -1
  49. package/internal/task/jira/acli/acli.go +0 -306
  50. package/internal/task/jira/acli/acli_test.go +0 -208
  51. package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
  52. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
  53. package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
  54. package/internal/task/jira/acli/testdata/search_success.json +0 -1
  55. /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
@@ -32,9 +32,10 @@ import (
32
32
 
33
33
  // Dependencies carries the replaceable boundaries used by activities.
34
34
  type Dependencies struct {
35
- Repos *repo.Registry
36
- Runner runner.Runner
37
- Harness harness.Harness
35
+ Repos *repo.Registry
36
+ Runner runner.Runner
37
+ Harness harness.Harness
38
+ TaskSystem string
38
39
 
39
40
  // RetentionDays bounds completed/canceled run retention; zero uses the
40
41
  // machine default of 30 days.
@@ -91,6 +92,23 @@ func InitDatabase(path string) error {
91
92
  return nil
92
93
  }
93
94
 
95
+ // HasNonterminalRuns inspects an existing database without migrating or
96
+ // otherwise modifying it. It is used by init --force before config changes.
97
+ func HasNonterminalRuns(path string) (bool, error) {
98
+ db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=ro", path))
99
+ if err != nil {
100
+ return false, fmt.Errorf("open %s: %w", path, err)
101
+ }
102
+ defer db.Close()
103
+ var active bool
104
+ if err := db.QueryRow(`SELECT EXISTS(
105
+ SELECT 1 FROM relay_runs WHERE state NOT IN ('completed', 'canceled')
106
+ )`).Scan(&active); err != nil {
107
+ return false, fmt.Errorf("inspect %s: %w", path, err)
108
+ }
109
+ return active, nil
110
+ }
111
+
94
112
  // New opens the SQLite database at path (created with mode 0600 when
95
113
  // missing), migrates the relay_runs projection, and constructs the engine.
96
114
  // A corrupt database file fails here.
@@ -130,10 +148,11 @@ func New(path string, deps Dependencies) (*Engine, error) {
130
148
  return nil, fmt.Errorf("migrate relay_runs: %w", err)
131
149
  }
132
150
  activities := &Activities{
133
- Repos: deps.Repos,
134
- Runner: deps.Runner,
135
- Harness: deps.Harness,
136
- Runs: proj,
151
+ Repos: deps.Repos,
152
+ Runner: deps.Runner,
153
+ Harness: deps.Harness,
154
+ TaskSystem: deps.TaskSystem,
155
+ Runs: proj,
137
156
  }
138
157
  retention := 30 * 24 * time.Hour
139
158
  if deps.RetentionDays > 0 {
@@ -204,6 +223,7 @@ func (e *Engine) registerActivities() error {
204
223
  a.ValidateAgents,
205
224
  a.ApplyTaskConfig,
206
225
  a.EnsureEnvironment,
226
+ a.SetEnvironmentStatus,
207
227
  a.LoadNodeRuntime,
208
228
  a.EnsureNodeRuntime,
209
229
  a.CloseTerminals,
@@ -291,6 +311,19 @@ func (e *Engine) EnsureRun(ctx context.Context, start run.Start) (bool, error) {
291
311
  if err != nil {
292
312
  return false, err
293
313
  }
314
+ if r.State != run.StateCompleted && r.State != run.StateCanceled && r.State != run.StateCanceling {
315
+ if _, err := e.instance(ctx, start.ID); errors.Is(err, sql.ErrNoRows) {
316
+ start.Runtime = e.runtime
317
+ if _, err := e.client.CreateWorkflowInstance(ctx,
318
+ client.WorkflowInstanceOptions{InstanceID: string(start.ID)},
319
+ e.activities.TicketWorkflow, start); err != nil && !errors.Is(err, backend.ErrInstanceAlreadyExists) {
320
+ return false, fmt.Errorf("create missing workflow instance %s: %w", start.ID, err)
321
+ }
322
+ return true, nil
323
+ } else if err != nil {
324
+ return false, err
325
+ }
326
+ }
294
327
  // Existing run: reconcile only an active run at a work node.
295
328
  if r.State == run.StateCompleted || r.State == run.StateCanceled || r.State == run.StateCanceling {
296
329
  return false, nil
@@ -304,7 +337,7 @@ func (e *Engine) EnsureRun(ctx context.Context, start run.Start) (bool, error) {
304
337
  }
305
338
  ok := false
306
339
  if runtime.TerminalID != "" {
307
- _, ok, _ = e.activities.Runner.InspectTerminal(ctx, runner.Terminal{
340
+ _, ok, _ = e.activities.Runner.FindTerminal(ctx, runner.Terminal{
308
341
  ID: runtime.TerminalID, Title: r.Ticket.Key + ":" + r.CurrentNode,
309
342
  })
310
343
  }
@@ -2,6 +2,7 @@ package goworkflows_test
2
2
 
3
3
  import (
4
4
  "context"
5
+ "database/sql"
5
6
  "path/filepath"
6
7
  "strings"
7
8
  "testing"
@@ -39,23 +40,25 @@ func linearWorkflow(cleanup bool) workflow.Workflow {
39
40
  }
40
41
  }
41
42
 
42
- func TestMailboxDescriptionRequiresQuestionForHITL(t *testing.T) {
43
+ func TestMailboxDescriptionAndLaunchPromptAreTaskSystemNeutral(t *testing.T) {
43
44
  wf := linearWorkflow(false)
44
45
  node := wf.Nodes["coding"]
45
46
  node.Type = workflow.NodeHITL
46
47
  description := goworkflows.MailboxSpecForNode(&wf, "PAY-101", "coding", node).Description
47
48
  for _, want := range []string{
49
+ "Parent ticket: PAY-101",
48
50
  "Do not make code changes",
49
51
  "until the human is satisfied with the review",
50
- "OpenCode's Question tool",
51
- "Approve and Reject",
52
- "If approved, output the report verbatim",
53
- "If rejected, return to step 1",
54
52
  } {
55
53
  if !strings.Contains(description, want) {
56
54
  t.Fatalf("HITL mailbox description missing %q:\n%s", want, description)
57
55
  }
58
56
  }
57
+ for _, unwanted := range []string{"Jira", "OpenCode", "Question tool"} {
58
+ if strings.Contains(description, unwanted) {
59
+ t.Fatalf("generic mailbox description contains %q:\n%s", unwanted, description)
60
+ }
61
+ }
59
62
 
60
63
  node.Type = workflow.NodeAgent
61
64
  agentDescription := goworkflows.MailboxSpecForNode(&wf, "PAY-101", "coding", node).Description
@@ -63,14 +66,18 @@ func TestMailboxDescriptionRequiresQuestionForHITL(t *testing.T) {
63
66
  t.Fatalf("agent mailbox description contains HITL Question instruction:\n%s", agentDescription)
64
67
  }
65
68
 
66
- prompt := goworkflows.BuildLaunchSpecPrompt("PAY-101", "PAY-234")
67
- for _, want := range []string{"parent Jira ticket PAY-101", "mailbox subtask is PAY-234", "description and comments"} {
68
- if !strings.Contains(prompt, want) {
69
- t.Fatalf("compact launch prompt missing %q: %q", want, prompt)
69
+ for _, taskSystem := range []string{"jira", "linear"} {
70
+ prompt := goworkflows.BuildLaunchSpecPrompt(taskSystem, "PAY-101", "PAY-234")
71
+ want := "Task system: " + taskSystem + "\nUse the " + taskSystem + " tools to read the parent ticket PAY-101.\n\nYour mailbox is PAY-234. Read its description and comments for node instructions and feedback."
72
+ if prompt != want {
73
+ t.Fatalf("BuildLaunchSpecPrompt(%s) = %q, want %q", taskSystem, prompt, want)
74
+ }
75
+ if strings.Contains(prompt, "Jira") || strings.Contains(prompt, "subtask") {
76
+ t.Fatalf("launch prompt contains task-system-specific mailbox wording: %q", prompt)
77
+ }
78
+ if strings.Contains(prompt, "STATUS:") || strings.Contains(prompt, node.Description) {
79
+ t.Fatalf("launch prompt duplicates mailbox instructions: %q", prompt)
70
80
  }
71
- }
72
- if strings.Contains(prompt, "STATUS:") || strings.Contains(prompt, node.Description) {
73
- t.Fatalf("launch prompt duplicates mailbox instructions: %q", prompt)
74
81
  }
75
82
  }
76
83
 
@@ -104,6 +111,50 @@ func newEngine(t *testing.T, deps goworkflows.Dependencies) *goworkflows.Engine
104
111
  return e
105
112
  }
106
113
 
114
+ func TestEnsureRunCreatesMissingWorkflowForExistingProjection(t *testing.T) {
115
+ log := newEventLog()
116
+ sys := newFakeTaskSystem(log)
117
+ path := filepath.Join(t.TempDir(), "state.db")
118
+ deps := goworkflows.Dependencies{
119
+ Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
120
+ }
121
+ engine, err := goworkflows.New(path, deps)
122
+ if err != nil {
123
+ t.Fatal(err)
124
+ }
125
+ wf := linearWorkflow(false)
126
+ rid := identity.NewRunID("payments", wf.Name, "PAY-101")
127
+ db, err := sql.Open("sqlite", path)
128
+ if err != nil {
129
+ t.Fatal(err)
130
+ }
131
+ if _, err := db.Exec(`INSERT INTO relay_runs
132
+ (id, repo, workflow, ticket_id, ticket_key, state, started_at, updated_at)
133
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, rid, "payments", wf.Name, "1", "PAY-101", run.StateStarting, time.Now(), time.Now()); err != nil {
134
+ t.Fatal(err)
135
+ }
136
+ db.Close()
137
+ if err := engine.Start(context.Background()); err != nil {
138
+ t.Fatal(err)
139
+ }
140
+ t.Cleanup(func() { _ = engine.Shutdown(context.Background()) })
141
+
142
+ created, err := engine.EnsureRun(context.Background(), run.Start{
143
+ ID: rid, Repo: "payments", RepoPath: "/srv/payments", Workflow: wf,
144
+ Ticket: task.TicketRef{ID: "1", Key: "PAY-101"},
145
+ })
146
+ if err != nil {
147
+ t.Fatal(err)
148
+ }
149
+ if !created {
150
+ t.Fatal("EnsureRun did not create the missing workflow instance")
151
+ }
152
+ waitFor(t, 10*time.Second, func() bool {
153
+ r, err := engine.GetRun(context.Background(), rid)
154
+ return err == nil && r.CurrentNode == "coding"
155
+ })
156
+ }
157
+
107
158
  func successReport(next string) workflow.Report {
108
159
  none := "None"
109
160
  return workflow.Report{
@@ -233,6 +284,9 @@ func TestRevisitCreatesNewVisit(t *testing.T) {
233
284
  r, _ := engine.GetRun(context.Background(), rid)
234
285
  return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
235
286
  })
287
+ if indexOf(log.all(), "environmentStatus:in-progress") < 0 {
288
+ t.Fatalf("agent node did not set workspace status in-progress; events=%v", log.all())
289
+ }
236
290
 
237
291
  r, _ := engine.GetRun(context.Background(), rid)
238
292
  first := r.CurrentNodeVisitID
@@ -329,6 +383,9 @@ func TestEndCleanupDisabledKeepsRetainedRunner(t *testing.T) {
329
383
  r, _ := engine.GetRun(context.Background(), rid)
330
384
  return r.State == run.StateCompleted
331
385
  })
386
+ if indexOf(log.all(), "environmentStatus:completed") < 0 {
387
+ t.Fatalf("end did not set workspace status completed; events=%v", log.all())
388
+ }
332
389
  if len(fr.cleaned) != 0 || fr.liveTerminals() != 1 {
333
390
  t.Fatalf("cleanup disabled: CleanupRun calls=%v live terminals=%d, want 0 and 1", fr.cleaned, fr.liveTerminals())
334
391
  }
@@ -378,6 +435,10 @@ func TestTransitionOrdering(t *testing.T) {
378
435
  r, _ := engine.GetRun(context.Background(), rid)
379
436
  return r.CurrentNode == "review"
380
437
  })
438
+ events := log.all()
439
+ if statusIdx, terminalIdx := indexOf(events, "environmentStatus:in-review"), indexOf(events, "ensureTerminal:PAY-101:review"); statusIdx < 0 || terminalIdx < 0 || statusIdx >= terminalIdx {
440
+ t.Fatalf("HITL status was not set before terminal start; events=%v", events)
441
+ }
381
442
 
382
443
  // Exact cross-primitive order, observed through the fake-adapter and fake-
383
444
  // runner call logs (the settled observation seam):
@@ -388,7 +449,6 @@ func TestTransitionOrdering(t *testing.T) {
388
449
  // (recovery_test.go): a crash after acceptance, with comment injection
389
450
  // failing, restarts on the same db and resumes the PERSISTED selected route
390
451
  // without re-asking the agent or re-running effects.
391
- events := log.all()
392
452
  idx := map[string]int{}
393
453
  for _, want := range []string{
394
454
  "comment:PAY-101-coding", // summary to current mailbox
@@ -337,21 +337,17 @@ func (f *fakeRunner) EnsureEnvironment(_ context.Context, spec runner.RunSpec) (
337
337
  return e, nil
338
338
  }
339
339
 
340
- func (f *fakeRunner) FindTerminal(_ context.Context, env runner.Environment, title string) (runner.Terminal, bool, error) {
341
- f.mu.Lock()
342
- defer f.mu.Unlock()
343
- f.log.add("findTerminal:" + title)
344
- ft, ok := f.terminals[env.ID+"/"+title]
345
- if !ok || !ft.live {
346
- return runner.Terminal{}, false, nil
347
- }
348
- return ft.term, true, nil
340
+ func (f *fakeRunner) SetEnvironmentStatus(_ context.Context, _ runner.Environment, status string) error {
341
+ f.log.add("environmentStatus:" + status)
342
+ return nil
349
343
  }
350
344
 
351
- func (f *fakeRunner) InspectTerminal(_ context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
345
+ func (f *fakeRunner) FindTerminal(_ context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
352
346
  f.mu.Lock()
353
347
  defer f.mu.Unlock()
354
- f.log.add("inspectTerminal:" + terminal.ID)
348
+ if terminal.ID != "" {
349
+ f.log.add("findTerminalID:" + terminal.ID)
350
+ }
355
351
  for _, ft := range f.terminals {
356
352
  if ft.term.ID == terminal.ID && ft.live {
357
353
  return ft.term, true, nil
@@ -381,17 +377,13 @@ func (f *fakeRunner) CreateTerminal(ctx context.Context, env runner.Environment,
381
377
  return t, nil
382
378
  }
383
379
 
384
- func (f *fakeRunner) EnsureTerminal(_ context.Context, env runner.Environment, title string, _ runner.Command) (runner.Terminal, error) {
385
- f.mu.Lock()
386
- defer f.mu.Unlock()
387
- key := env.ID + "/" + title
388
- if ft, ok := f.terminals[key]; ok && ft.live {
389
- return ft.term, nil
380
+ func (f *fakeRunner) EnsureTerminal(ctx context.Context, env runner.Environment, stored runner.Terminal, title string, command runner.Command) (runner.Terminal, error) {
381
+ if terminal, ok, err := f.FindTerminal(ctx, stored); err != nil {
382
+ return runner.Terminal{}, err
383
+ } else if ok {
384
+ return terminal, nil
390
385
  }
391
- t := runner.Terminal{ID: "t-" + key, Title: title}
392
- f.terminals[key] = &fakeTerminal{term: t, live: true, title: title}
393
- f.log.add("ensureTerminal:" + title)
394
- return t, nil
386
+ return f.CreateTerminal(ctx, env, title, command)
395
387
  }
396
388
 
397
389
  func (f *fakeRunner) CloseTerminal(_ context.Context, t runner.Terminal) error {
@@ -194,11 +194,12 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
194
194
  // are also retained separately for live-terminal revisits.
195
195
  nextSteps := append(append([]workflow.Route{}, node.OnSuccess...), node.OnFailure...)
196
196
  nudge, err := wf.RenderNudge(current, workflow.NudgeTemplateData{
197
- Ticket: start.Ticket.Key,
198
- Workflow: wf.Name,
199
- Repo: start.Repo,
200
- Node: current,
201
- NextSteps: nextStepsText(nextSteps),
197
+ TaskSystem: a.TaskSystem,
198
+ Ticket: start.Ticket.Key,
199
+ Workflow: wf.Name,
200
+ Repo: start.Repo,
201
+ Node: current,
202
+ NextSteps: nextStepsText(nextSteps),
202
203
  })
203
204
  if err != nil {
204
205
  return err
@@ -214,7 +215,7 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
214
215
  NodeType: node.Type,
215
216
  Agent: node.Agent,
216
217
  Title: title,
217
- Prompt: BuildLaunchSpecPrompt(start.Ticket.Key, mb.Key),
218
+ Prompt: BuildLaunchSpecPrompt(a.TaskSystem, start.Ticket.Key, mb.Key),
218
219
  NudgePrompt: nudge,
219
220
  NextSteps: nextSteps,
220
221
  }
@@ -360,6 +361,13 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
360
361
  }); err != nil {
361
362
  return err
362
363
  }
364
+ if _, err := retryLoop(ctx, start.ID, a, work, "end",
365
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
366
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries,
367
+ a.SetEnvironmentStatus, work, start.RepoPath, runner.WorkspaceStatusCompleted)
368
+ }); err != nil {
369
+ return err
370
+ }
363
371
  finalPolicy := work.Runtime
364
372
  if wf.CleanupRunnerOnEnd {
365
373
  finalPolicy.KeepTerminalsAlive = false
@@ -401,7 +409,7 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
401
409
  // run-level activities like EnsureMailboxes / start/end ApplyTaskConfig).
402
410
  // The log is wrapped in a replay-safe SideEffect so replays never re-emit
403
411
  // it, and the error message is sanitized so info logs never embed argv
404
- // payloads (acli/orca --body/--command/JQL/prompt strings).
412
+ // payloads (Jira/Orca request bodies, commands, JQL, and prompt strings).
405
413
  func retryLoop[T any](ctx goworkflow.Context, id run.ID, a *Activities, work run.Work, node string, schedule func(goworkflow.Context) goworkflow.Future[T]) (T, error) {
406
414
  var zero T
407
415
  attempt := 0
@@ -475,7 +483,7 @@ func logRetry(ctx goworkflow.Context, work run.Work, node string, f retry.Failur
475
483
  }
476
484
 
477
485
  // sanitizeRetryMessage strips each "[...]: " argv-listing span that adapter
478
- // wrappers embed (acli [args...]:, orca [args...]:) so the info-level retry
486
+ // wrappers may embed argv/request context, so the info-level retry
479
487
  // record never leaks --body/--command/JQL/prompt payloads. Keeps the
480
488
  // surrounding wrap context and trailing exit status / stderr fragment that
481
489
  // carry the failure reason. The original error returned to callers is
@@ -84,8 +84,8 @@ func TestPersistedRuntimeLoopAndRestart(t *testing.T) {
84
84
  if err != nil || !staleAck.Duplicate {
85
85
  t.Fatalf("stale ack=%+v err=%v", staleAck, err)
86
86
  }
87
- if log.count("findTerminal:") != 0 {
88
- t.Fatalf("normal path used title discovery: %v", log.all())
87
+ if log.count("findTerminalID:") == 0 {
88
+ t.Fatalf("normal path did not check persisted terminal IDs: %v", log.all())
89
89
  }
90
90
  if log.count("findSession:") != 0 {
91
91
  t.Fatalf("normal path used session discovery: %v", log.all())
@@ -93,7 +93,7 @@ func TestPersistedRuntimeLoopAndRestart(t *testing.T) {
93
93
 
94
94
  oldTerminal := secondRT.TerminalID
95
95
  fr.killTerminals()
96
- findTerminalBefore := log.count("findTerminal:")
96
+ findTerminalBefore := log.count("findTerminalID:")
97
97
  findSessionBefore := log.count("findSession:")
98
98
  e2 := restartEngine(t, db, deps, e1)
99
99
  waitFor(t, 10*time.Second, func() bool {
@@ -104,13 +104,19 @@ func TestPersistedRuntimeLoopAndRestart(t *testing.T) {
104
104
  t.Fatal(err)
105
105
  }
106
106
  resumeEvent := "buildCommand:implement:" + string(second.CurrentNodeVisitID) + ":resume=session-implement"
107
- waitFor(t, 10*time.Second, func() bool { return log.count(resumeEvent) == 1 })
107
+ waitFor(t, 10*time.Second, func() bool {
108
+ runtime, _ := e2.GetNodeRuntime(context.Background(), rid, "implement")
109
+ return runtime.TerminalID != "" && runtime.TerminalID != oldTerminal
110
+ })
108
111
  after, _ := e2.GetNodeRuntime(context.Background(), rid, "implement")
109
112
  if after.TerminalID == oldTerminal || after.SessionID != "session-implement" || after.NodeVisitID != second.CurrentNodeVisitID {
110
113
  t.Fatalf("restart did not resume/replace direct runtime: old=%+v after=%+v", secondRT, after)
111
114
  }
112
- if log.count("findTerminal:") != findTerminalBefore || log.count("findSession:") != findSessionBefore {
113
- t.Fatalf("restart used discovery: %v", log.all())
115
+ if log.count("findTerminalID:") == findTerminalBefore || log.count("findSession:") != findSessionBefore {
116
+ t.Fatalf("restart did not use only the persisted terminal/session IDs: %v", log.all())
117
+ }
118
+ if log.count(resumeEvent) < 1 {
119
+ t.Fatalf("restart did not pass persisted session ID to harness: %v", log.all())
114
120
  }
115
121
  }
116
122
 
@@ -189,7 +189,7 @@ func TestNodeRuntimeSessionRegistrationKeepsOldSessionBoundToOldVisit(t *testing
189
189
 
190
190
  func TestEnsureNodeRuntimeUsesDirectIDsAndFallsBackFresh(t *testing.T) {
191
191
  ctx := context.Background()
192
- fr := &runtimeTestRunner{createErr: runner.ErrSessionUnavailable}
192
+ fr := &runtimeTestRunner{}
193
193
  fh := &runtimeTestHarness{}
194
194
  db := openProjectionDB(t, filepath.Join(t.TempDir(), "state.db"))
195
195
  defer db.Close()
@@ -219,11 +219,17 @@ func TestEnsureNodeRuntimeUsesDirectIDsAndFallsBackFresh(t *testing.T) {
219
219
  if err != nil {
220
220
  t.Fatal(err)
221
221
  }
222
- if rt.TerminalID == "" || rt.TerminalID == "dead-term" || rt.SessionID != "" {
222
+ if rt.TerminalID == "" || rt.TerminalID == "dead-term" || rt.SessionID != "dead-session" {
223
223
  t.Fatalf("failed direct IDs not replaced atomically: %+v", rt)
224
224
  }
225
- if fr.findCalls != 0 || fh.buildCalls != 2 || fr.createCalls != 2 {
226
- t.Fatalf("fallback used discovery or wrong launch count: find=%d build=%d create=%d", fr.findCalls, fh.buildCalls, fr.createCalls)
225
+ if fr.findCalls != 1 || fh.buildCalls != 1 || fr.createCalls != 1 {
226
+ t.Fatalf("stored-ID replacement calls: find=%d build=%d create=%d", fr.findCalls, fh.buildCalls, fr.createCalls)
227
+ }
228
+ if len(fr.findIDs) != 1 || fr.findIDs[0] != "dead-term" {
229
+ t.Fatalf("FindTerminal IDs = %v, want [dead-term]", fr.findIDs)
230
+ }
231
+ if len(fh.resumeIDs) != 1 || fh.resumeIDs[0] != "dead-session" {
232
+ t.Fatalf("BuildCommand ResumeIDs = %v, want [dead-session]", fh.resumeIDs)
227
233
  }
228
234
  for _, prompt := range fh.prompts {
229
235
  if prompt != "work" {
@@ -248,15 +254,19 @@ func TestEnsureNodeRuntimeInitialLaunchAppendsCustomInstructions(t *testing.T) {
248
254
  t.Fatal(err)
249
255
  }
250
256
  fh := &runtimeTestHarness{}
251
- a := &Activities{Runner: &runtimeTestRunner{}, Harness: fh, Runs: p}
257
+ fr := &runtimeTestRunner{}
258
+ a := &Activities{Runner: fr, Harness: fh, Runs: p}
252
259
  nw := run.NodeWork{Work: run.Work{RunID: id}, Node: "implement", NodeVisitID: "visit-first"}
253
- spec := harness.LaunchSpec{RunID: id, NodeVisitID: "visit-first", Node: "implement", Agent: "build", Prompt: "standard prompt", NudgePrompt: "custom instructions"}
260
+ spec := harness.LaunchSpec{RunID: id, NodeVisitID: "visit-first", Node: "implement", NodeType: workflow.NodeHITL, Agent: "build", Prompt: "standard prompt", NudgePrompt: "custom instructions"}
254
261
  if err := a.EnsureNodeRuntime(ctx, nw, "", spec, NodeRuntime{}); err != nil {
255
262
  t.Fatal(err)
256
263
  }
257
264
  if len(fh.prompts) != 1 || fh.prompts[0] != "standard prompt\n\ncustom instructions" {
258
265
  t.Fatalf("initial prompt = %q", fh.prompts)
259
266
  }
267
+ if len(fr.statuses) != 1 || fr.statuses[0] != runner.WorkspaceStatusInReview {
268
+ t.Fatalf("HITL workspace statuses = %v, want in-review", fr.statuses)
269
+ }
260
270
  }
261
271
 
262
272
  func TestEnsureNodeRuntimeSendFailureClosesLiveTerminal(t *testing.T) {
@@ -295,9 +305,12 @@ func TestEnsureNodeRuntimeSendFailureClosesLiveTerminal(t *testing.T) {
295
305
  t.Fatalf("revisit replacement prompt = %q", fh.prompts)
296
306
  }
297
307
  rt, _ := p.getNodeRuntime(ctx, id, "implement")
298
- if rt.TerminalID == "live-old" || rt.SessionID != "" {
308
+ if rt.TerminalID == "live-old" || rt.SessionID != "session-old" {
299
309
  t.Fatalf("send failure did not replace IDs: %+v", rt)
300
310
  }
311
+ if len(fh.resumeIDs) != 1 || fh.resumeIDs[0] != "session-old" {
312
+ t.Fatalf("replacement ResumeIDs = %v, want [session-old]", fh.resumeIDs)
313
+ }
301
314
  }
302
315
 
303
316
  func TestEnsureNodeRuntimeSameVisitSendsNothing(t *testing.T) {
@@ -412,14 +425,16 @@ func TestEngineRuntimePolicyDefaultsKeepBoth(t *testing.T) {
412
425
  }
413
426
 
414
427
  type runtimeTestRunner struct {
415
- createErr error
416
428
  findCalls int
429
+ findIDs []string
417
430
  createCalls int
418
431
  live bool
432
+ liveID string
419
433
  sendErr error
420
434
  sentTexts []string
421
435
  closeCalls int
422
436
  closedIDs []string
437
+ statuses []string
423
438
  }
424
439
 
425
440
  func (*runtimeTestRunner) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) {
@@ -429,8 +444,17 @@ func (*runtimeTestRunner) ValidateRepo(context.Context, string, string) error {
429
444
  func (*runtimeTestRunner) EnsureEnvironment(context.Context, runner.RunSpec) (runner.Environment, error) {
430
445
  return runner.Environment{ID: "env"}, nil
431
446
  }
432
- func (r *runtimeTestRunner) InspectTerminal(_ context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
433
- return terminal, r.live, nil
447
+ func (r *runtimeTestRunner) SetEnvironmentStatus(_ context.Context, _ runner.Environment, status string) error {
448
+ r.statuses = append(r.statuses, status)
449
+ return nil
450
+ }
451
+ func (r *runtimeTestRunner) FindTerminal(_ context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
452
+ r.findCalls++
453
+ r.findIDs = append(r.findIDs, terminal.ID)
454
+ if !r.live || (r.liveID != "" && terminal.ID != r.liveID) {
455
+ return runner.Terminal{}, false, nil
456
+ }
457
+ return terminal, true, nil
434
458
  }
435
459
  func (r *runtimeTestRunner) SendTerminal(_ context.Context, _ runner.Terminal, text string) error {
436
460
  r.sentTexts = append(r.sentTexts, text)
@@ -438,16 +462,9 @@ func (r *runtimeTestRunner) SendTerminal(_ context.Context, _ runner.Terminal, t
438
462
  }
439
463
  func (r *runtimeTestRunner) CreateTerminal(context.Context, runner.Environment, string, runner.Command) (runner.Terminal, error) {
440
464
  r.createCalls++
441
- if r.createErr != nil {
442
- err := r.createErr
443
- r.createErr = nil
444
- return runner.Terminal{}, err
445
- }
446
- return runner.Terminal{ID: "fresh-term"}, nil
447
- }
448
- func (r *runtimeTestRunner) FindTerminal(context.Context, runner.Environment, string) (runner.Terminal, bool, error) {
449
- r.findCalls++
450
- return runner.Terminal{}, false, nil
465
+ r.live = true
466
+ r.liveID = "fresh-term"
467
+ return runner.Terminal{ID: r.liveID}, nil
451
468
  }
452
469
  func (r *runtimeTestRunner) CloseTerminal(_ context.Context, terminal runner.Terminal) error {
453
470
  r.closeCalls++
@@ -455,7 +472,12 @@ func (r *runtimeTestRunner) CloseTerminal(_ context.Context, terminal runner.Ter
455
472
  r.live = false
456
473
  return nil
457
474
  }
458
- func (r *runtimeTestRunner) EnsureTerminal(ctx context.Context, env runner.Environment, title string, command runner.Command) (runner.Terminal, error) {
475
+ func (r *runtimeTestRunner) EnsureTerminal(ctx context.Context, env runner.Environment, stored runner.Terminal, title string, command runner.Command) (runner.Terminal, error) {
476
+ if terminal, ok, err := r.FindTerminal(ctx, stored); err != nil {
477
+ return runner.Terminal{}, err
478
+ } else if ok {
479
+ return terminal, nil
480
+ }
459
481
  return r.CreateTerminal(ctx, env, title, command)
460
482
  }
461
483
  func (*runtimeTestRunner) CloseTerminals(context.Context, runner.RunSpec) error { return nil }
@@ -464,6 +486,7 @@ func (*runtimeTestRunner) CleanupRun(context.Context, runner.RunSpec) error
464
486
  type runtimeTestHarness struct {
465
487
  buildCalls int
466
488
  prompts []string
489
+ resumeIDs []string
467
490
  }
468
491
 
469
492
  func (*runtimeTestHarness) ValidateAgent(context.Context, string, string) error { return nil }
@@ -473,6 +496,7 @@ func (*runtimeTestHarness) FindSession(context.Context, string, string) (harness
473
496
  func (h *runtimeTestHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
474
497
  h.buildCalls++
475
498
  h.prompts = append(h.prompts, spec.Prompt)
499
+ h.resumeIDs = append(h.resumeIDs, spec.ResumeID)
476
500
  return runner.Command{Executable: "opencode", Args: []string{spec.ResumeID}}, nil
477
501
  }
478
502
 
@@ -490,7 +490,7 @@ func TestTerminalReconcile(t *testing.T) {
490
490
  terminalsBefore := fr.liveTerminals()
491
491
  relaunchBefore := log.count("ensureTerminal:PAY-101:coding")
492
492
  buildBefore := log.count("buildCommand:")
493
- inspectBefore := log.count("inspectTerminal:")
493
+ inspectBefore := log.count("findTerminalID:")
494
494
 
495
495
  // Healthy terminal: EnsureRun checks the persisted direct handle and,
496
496
  // finding it live, sends no reconcile and relaunches
@@ -502,7 +502,7 @@ func TestTerminalReconcile(t *testing.T) {
502
502
  t.Fatal(err)
503
503
  }
504
504
  time.Sleep(300 * time.Millisecond)
505
- if log.count("inspectTerminal:") == inspectBefore {
505
+ if log.count("findTerminalID:") == inspectBefore {
506
506
  t.Fatal("repeated EnsureRun never checked the persisted terminal handle")
507
507
  }
508
508
  if got := log.count("buildCommand:") - buildBefore; got != 0 {
@@ -792,7 +792,7 @@ func TestServeRecoverRebuildsFreshRuns(t *testing.T) {
792
792
  RepoName: "payments", RepoPath: "/srv/payments", TicketKey: "PAY-101",
793
793
  }
794
794
  env, _ := fr.EnsureEnvironment(context.Background(), survSpec)
795
- _, _ = fr.EnsureTerminal(context.Background(), env, "PAY-101:coding", runner.Command{})
795
+ _, _ = fr.EnsureTerminal(context.Background(), env, runner.Terminal{}, "PAY-101:coding", runner.Command{})
796
796
 
797
797
  deps := goworkflows.Dependencies{Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: fh}
798
798
 
@@ -821,7 +821,7 @@ func TestServeRecoverRebuildsFreshRuns(t *testing.T) {
821
821
  if err != nil || preRuntime.TerminalID == "" {
822
822
  t.Fatalf("pre-loss runtime = %+v, %v", preRuntime, err)
823
823
  }
824
- inspectBeforeRecover := log.count("inspectTerminal:")
824
+ inspectBeforeRecover := log.count("findTerminalID:")
825
825
  pc, pcancel := context.WithTimeout(context.Background(), 30*time.Second)
826
826
  _ = preEngine.Shutdown(pc)
827
827
  pcancel()
@@ -871,7 +871,7 @@ func TestServeRecoverRebuildsFreshRuns(t *testing.T) {
871
871
  t.Fatalf("%s recovery reused pre-loss terminal ID %q", key, rt.TerminalID)
872
872
  }
873
873
  }
874
- if log.count("inspectTerminal:") != inspectBeforeRecover {
874
+ if log.count("findTerminalID:") != inspectBeforeRecover {
875
875
  t.Fatalf("recover used pre-loss direct terminal IDs: %v", log.all())
876
876
  }
877
877
  if log.count("closeTerminals:") == 0 {
@@ -6,7 +6,7 @@ import (
6
6
  )
7
7
 
8
8
  // 9.6: the info-level retry record must never embed argv payloads
9
- // (acli --body, orca --command carrying the agent prompt + RELAY_FLOW_*
9
+ // (Jira/Orca request bodies or commands carrying prompts and RELAY_FLOW_*
10
10
  // env, JQL). sanitizeRetryMessage strips each "[args...]: " span while
11
11
  // preserving the surrounding wrap context and trailing stderr fragment.
12
12
  func TestSanitizeRetryMessage(t *testing.T) {
@@ -19,25 +19,25 @@ func TestSanitizeRetryMessage(t *testing.T) {
19
19
  mustContain []string
20
20
  }{
21
21
  {
22
- name: "acli comment with body payload",
23
- in: `acli [jira workitem comment create --key PAY-1 --body SECRET-BODY --json]: exit status 1: permission denied`,
24
- mustNot: []string{"SECRET-BODY", "--body", "--key PAY-1"},
22
+ name: "acli comment with body payload",
23
+ in: `acli [jira workitem comment create --key PAY-1 --body SECRET-BODY --json]: exit status 1: permission denied`,
24
+ mustNot: []string{"SECRET-BODY", "--body", "--key PAY-1"},
25
25
  mustContain: []string{"acli", "exit status 1", "permission denied"},
26
26
  },
27
27
  {
28
- name: "orca create with command payload",
29
- in: `ensure run X: orca terminal create: orca [terminal create --worktree name:PAY-1 --title PAY-1:coding --command 'RELAY_FLOW_RUN_ID=r1 opencode --agent coder PROMPT-TEXT']: exit status 1: closed`,
30
- mustNot: []string{"PROMPT-TEXT", "RELAY_FLOW_RUN_ID=r1", "--command", "--title PAY-1:coding"},
28
+ name: "orca create with command payload",
29
+ in: `ensure run X: orca terminal create: orca [terminal create --worktree name:PAY-1 --title PAY-1:coding --command 'RELAY_FLOW_RUN_ID=r1 opencode --agent coder PROMPT-TEXT']: exit status 1: closed`,
30
+ mustNot: []string{"PROMPT-TEXT", "RELAY_FLOW_RUN_ID=r1", "--command", "--title PAY-1:coding"},
31
31
  mustContain: []string{"ensure run X", "orca terminal create", "exit status 1", "closed"},
32
32
  },
33
33
  {
34
- name: "no brackets passes through",
35
- in: "plain failure",
34
+ name: "no brackets passes through",
35
+ in: "plain failure",
36
36
  mustContain: []string{"plain failure"},
37
37
  },
38
38
  {
39
- name: "unterminated bracket passes through",
40
- in: "weird [unterminated",
39
+ name: "unterminated bracket passes through",
40
+ in: "weird [unterminated",
41
41
  mustContain: []string{"weird"},
42
42
  },
43
43
  }
@@ -23,6 +23,8 @@ type fakeHarness struct {
23
23
  session map[string]harness.Session // title -> session
24
24
  }
25
25
 
26
+ var _ harness.Harness = (*fakeHarness)(nil)
27
+
26
28
  func newFakeHarness() *fakeHarness {
27
29
  return &fakeHarness{
28
30
  agents: map[string]bool{"build": true},
@@ -141,6 +143,9 @@ func TestBuildCommandEnvContract(t *testing.T) {
141
143
  if cmd.Env["RELAY_FLOW_TICKET"] != "PAY-101" || cmd.Env["RELAY_FLOW_NODE"] != "coding" {
142
144
  t.Fatalf("ticket/node env wrong: %v", cmd.Env)
143
145
  }
146
+ if _, ok := cmd.Env["RELAY_FLOW_NODE_VISIT_ID"]; ok {
147
+ t.Fatalf("internal node visit ID leaked into harness env: %v", cmd.Env)
148
+ }
144
149
 
145
150
  // NEXT_STEPS_JSON must decode to the legal targets and their when
146
151
  // explanations, not just exist.