relay-flow 0.2.10-alpha → 0.3.0-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.
@@ -162,7 +162,8 @@ func (a *Activities) LoadNodeRuntime(ctx context.Context, id run.ID, node string
162
162
 
163
163
  // EnsureNodeRuntime uses only persisted terminal/session IDs on the normal
164
164
  // path. A live terminal is rebound to the new visit; otherwise EnsureTerminal
165
- // creates a replacement and its direct ID is persisted immediately.
165
+ // creates a replacement and its direct ID is persisted immediately. A stored
166
+ // session receives feedback whether its terminal is reused or replaced.
166
167
  func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, repoPath string, spec harness.LaunchSpec, rt NodeRuntime) (NodeRuntime, error) {
167
168
  a.runtimeMu.Lock()
168
169
  defer a.runtimeMu.Unlock()
@@ -204,6 +205,10 @@ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, rep
204
205
  rt.TerminalID = currentRuntime.TerminalID
205
206
  rt.SessionID = currentRuntime.SessionID
206
207
  spec.ResumeID = rt.SessionID
208
+ promptKind := harness.PromptInitial
209
+ if rt.SessionID != "" {
210
+ promptKind = harness.PromptFeedback
211
+ }
207
212
  stored := runner.Terminal{ID: rt.TerminalID, Title: spec.Title}
208
213
  terminal, live, err := a.Runner.FindTerminal(ctx, stored)
209
214
  if err != nil {
@@ -218,7 +223,7 @@ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, rep
218
223
  // Same-visit retry/restart is silent: do not render, build, or send.
219
224
  return rt, nil
220
225
  }
221
- prompt, err := a.Harness.RenderPrompt(harness.PromptFeedback, spec.PromptData, spec.NudgePrompt)
226
+ prompt, err := a.Harness.RenderPrompt(promptKind, spec.PromptData, spec.NudgePrompt)
222
227
  if err != nil {
223
228
  return NodeRuntime{}, err
224
229
  }
@@ -232,14 +237,14 @@ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, rep
232
237
  }
233
238
  }
234
239
 
235
- // An initial or replacement terminal resumes the stored session and
236
- // receives the rendered initial prompt. Same-visit replacements omit the
237
- // node nudge; a new visit includes it.
240
+ // A fresh session receives the initial prompt; a stored session receives
241
+ // feedback even when its terminal must be replaced. Same-visit
242
+ // replacements omit the node nudge; a new visit includes it.
238
243
  nudge := ""
239
244
  if rt.NodeVisitID == "" || revisit {
240
245
  nudge = spec.NudgePrompt
241
246
  }
242
- spec.Prompt, err = a.Harness.RenderPrompt(harness.PromptInitial, spec.PromptData, nudge)
247
+ spec.Prompt, err = a.Harness.RenderPrompt(promptKind, spec.PromptData, nudge)
243
248
  if err != nil {
244
249
  return NodeRuntime{}, err
245
250
  }
@@ -0,0 +1,147 @@
1
+ package temporal
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "testing"
7
+ "time"
8
+
9
+ "github.com/rajpopat27/relay-flow/internal/run"
10
+ "github.com/rajpopat27/relay-flow/internal/runner"
11
+ "github.com/rajpopat27/relay-flow/internal/task"
12
+ "github.com/rajpopat27/relay-flow/internal/workflow"
13
+ "go.temporal.io/sdk/testsuite"
14
+ temporalworkflow "go.temporal.io/sdk/workflow"
15
+ )
16
+
17
+ // The workflow uses a start-to-end graph so the test isolates end cleanup;
18
+ // terminalLive models the persisted node terminal that finalization would close.
19
+ type endCleanupActivities struct {
20
+ events []string
21
+ cleanupDirty bool
22
+ terminalLive bool
23
+ completionObserved bool
24
+ completionObservedOnFailure bool
25
+ }
26
+
27
+ func (a *endCleanupActivities) EnsureMailboxes(context.Context, run.Work, []task.MailboxSpec) (map[string]task.Mailbox, error) {
28
+ return map[string]task.Mailbox{}, nil
29
+ }
30
+
31
+ func (a *endCleanupActivities) ValidateAgents(context.Context, string, []string) error {
32
+ return nil
33
+ }
34
+
35
+ func (a *endCleanupActivities) ProjectionUpsertStep(context.Context, run.StepEntry) error {
36
+ return nil
37
+ }
38
+
39
+ func (a *endCleanupActivities) ApplyTaskConfig(context.Context, run.Work, string, *task.Mailbox, map[string]any) error {
40
+ return nil
41
+ }
42
+
43
+ func (a *endCleanupActivities) EnsureEnvironment(context.Context, run.Work, string) (runner.Environment, error) {
44
+ return runner.Environment{}, nil
45
+ }
46
+
47
+ func (a *endCleanupActivities) SetEnvironmentStatus(context.Context, run.Work, string, string) error {
48
+ return nil
49
+ }
50
+
51
+ func (a *endCleanupActivities) CleanupRun(context.Context, run.Work, string) error {
52
+ if a.cleanupDirty {
53
+ a.cleanupDirty = false
54
+ a.completionObservedOnFailure = a.completionObserved
55
+ if a.terminalLive {
56
+ a.events = append(a.events, "cleanup-failed-terminal-live")
57
+ } else {
58
+ a.events = append(a.events, "cleanup-failed-terminal-closed")
59
+ }
60
+ return errors.New("ticket checkout is dirty; commit required before runner cleanup")
61
+ }
62
+ a.events = append(a.events, "cleanup")
63
+ return nil
64
+ }
65
+
66
+ func (a *endCleanupActivities) FinalizeNodeRuntimes(context.Context, run.Work, string, run.RuntimePolicy) error {
67
+ if a.terminalLive {
68
+ a.terminalLive = false
69
+ a.events = append(a.events, "finalize-terminal-closed")
70
+ } else {
71
+ a.events = append(a.events, "finalize-terminal-already-closed")
72
+ }
73
+ return nil
74
+ }
75
+
76
+ func (a *endCleanupActivities) ProjectionUpdateRetry(context.Context, run.ID, *run.RetryStatus) error {
77
+ return nil
78
+ }
79
+
80
+ func (a *endCleanupActivities) ProjectionUpdateState(_ context.Context, _ run.ID, state run.State, _ string, _ *time.Time) error {
81
+ if state == run.StateCompleted {
82
+ a.completionObserved = true
83
+ if a.terminalLive {
84
+ a.events = append(a.events, "completed-terminal-live")
85
+ } else {
86
+ a.events = append(a.events, "completed")
87
+ }
88
+ }
89
+ return nil
90
+ }
91
+
92
+ func endCleanupOrderingWorkflow(ctx temporalworkflow.Context, start run.Start) error {
93
+ state := newWorkflowState(ctx, start)
94
+ reason := "canceled"
95
+ return runGraph(ctx, start, state, temporalworkflow.GetSignalChannel(ctx, cancelReasonSignalName), &reason)
96
+ }
97
+
98
+ func TestTemporalEndCleanupRunsBeforeRuntimeFinalization(t *testing.T) {
99
+ for _, cleanup := range []bool{true, false} {
100
+ t.Run(map[bool]string{true: "enabled", false: "disabled"}[cleanup], func(t *testing.T) {
101
+ activities := &endCleanupActivities{cleanupDirty: cleanup, terminalLive: true}
102
+ var suite testsuite.WorkflowTestSuite
103
+ env := suite.NewTestWorkflowEnvironment()
104
+ env.RegisterWorkflow(endCleanupOrderingWorkflow)
105
+ env.RegisterActivity(activities)
106
+ start := run.Start{
107
+ ID: "repo/cleanup/T-1", Repo: "repo", RepoPath: t.TempDir(),
108
+ Workflow: workflow.Workflow{
109
+ Name: "cleanup", CleanupRunnerOnEnd: cleanup,
110
+ Nodes: map[string]workflow.Node{
111
+ "start": {OnSuccess: []workflow.Route{{Target: "end"}}},
112
+ "end": {},
113
+ },
114
+ },
115
+ Ticket: task.TicketRef{ID: "T-1", Key: "T-1"},
116
+ }
117
+ env.ExecuteWorkflow(endCleanupOrderingWorkflow, start)
118
+ if err := env.GetWorkflowError(); err != nil {
119
+ t.Fatalf("workflow error: %v", err)
120
+ }
121
+ if cleanup {
122
+ if activities.completionObservedOnFailure {
123
+ t.Fatal("run was marked completed while dirty cleanup was retrying")
124
+ }
125
+ want := []string{"cleanup-failed-terminal-live", "cleanup", "finalize-terminal-closed", "completed"}
126
+ if len(activities.events) != len(want) {
127
+ t.Fatalf("activity order = %v, want %v", activities.events, want)
128
+ }
129
+ for i := range want {
130
+ if activities.events[i] != want[i] {
131
+ t.Fatalf("activity order = %v, want %v", activities.events, want)
132
+ }
133
+ }
134
+ } else {
135
+ want := []string{"finalize-terminal-closed", "completed"}
136
+ if len(activities.events) != len(want) {
137
+ t.Fatalf("cleanup-disabled activity order = %v, want %v", activities.events, want)
138
+ }
139
+ for i := range want {
140
+ if activities.events[i] != want[i] {
141
+ t.Fatalf("cleanup-disabled activity order = %v, want %v", activities.events, want)
142
+ }
143
+ }
144
+ }
145
+ })
146
+ }
147
+ }
@@ -597,6 +597,17 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
597
597
  if wf.CleanupRunnerOnEnd {
598
598
  finalPolicy.KeepTerminalsAlive = false
599
599
  }
600
+ // Cleanup must run before finalizing node runtimes when enabled. The
601
+ // runner performs its Git cleanliness check before closing terminals, so a
602
+ // dirty checkout leaves the agent terminal available for a commit while
603
+ // retryActivity waits.
604
+ if wf.CleanupRunnerOnEnd {
605
+ if _, err := retryActivity(ctx, state, work, "", func() (struct{}, error) {
606
+ return executeActivity[struct{}](ctx, activityCleanupRun, work, start.RepoPath)
607
+ }); err != nil {
608
+ return err
609
+ }
610
+ }
600
611
  if _, err := retryActivity(ctx, state, work, "", func() (struct{}, error) {
601
612
  return executeActivity[struct{}](ctx, activityFinalizeNodeRuntimes, work, start.RepoPath, finalPolicy)
602
613
  }); err != nil {
@@ -605,13 +616,6 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
605
616
  for node := range state.bindings {
606
617
  applyRuntimePolicy(state, node, finalPolicy)
607
618
  }
608
- if wf.CleanupRunnerOnEnd {
609
- if _, err := retryActivity(ctx, state, work, "", func() (struct{}, error) {
610
- return executeActivity[struct{}](ctx, activityCleanupRun, work, start.RepoPath)
611
- }); err != nil {
612
- return err
613
- }
614
- }
615
619
  now := temporalworkflow.Now(ctx).UTC()
616
620
  endStep.Status, endStep.FinishedAt = run.StepSucceeded, &now
617
621
  if _, err := retryActivity(ctx, state, work, "end", func() (struct{}, error) {
@@ -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.2.10-alpha"
17
+ const configuredPlugin = "relay-flow-plugin@0.3.0-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.2.10-alpha"
13
+ const relayFlowPlugin = "relay-flow-plugin@0.3.0-alpha"
14
14
 
15
15
  type jsoncToken struct {
16
16
  kind byte
@@ -148,10 +148,11 @@ func (*Harness) FindSession(context.Context, string, string) (harness.Session, b
148
148
  }
149
149
 
150
150
  // RenderPrompt renders the selected initial or feedback template and the
151
- // node's nudge template. Named Pi agents are native prompt-template commands,
152
- // so the complete rendered text is supplied as the command arguments. HITL
153
- // approval is not encoded in the prompt; the Pi extension asks for approval
154
- // through ctx.ui.select.
151
+ // node's nudge template. Initial prompts use Pi's native prompt-template
152
+ // command syntax; feedback is sent to an existing session and must remain
153
+ // raw so Pi does not expand the full prompt template again. HITL approval is
154
+ // not encoded in the prompt; the Pi extension asks for approval through
155
+ // ctx.ui.select.
155
156
  func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData, nudgeTemplate string) (string, error) {
156
157
  var tmpl string
157
158
  switch kind {
@@ -168,15 +169,20 @@ func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData,
168
169
  }
169
170
  }
170
171
  prompt := appendPrompt(renderTemplate(tmpl, data), renderTemplate(nudgeTemplate, data))
171
- return applyPromptTemplate(data.Agent, prompt), nil
172
+ if kind == harness.PromptInitial {
173
+ return applyPromptTemplate(data.Agent, prompt), nil
174
+ }
175
+ return prompt, nil
172
176
  }
173
177
 
174
178
  // BuildCommand returns the interactive Pi invocation. The runner supplies a
175
179
  // PTY for Pi's stdin/stdout; the rendered prompt is the final positional argv
176
180
  // value. A named workflow agent adds Pi's --prompt-template option for the
177
181
  // project-owned .pi/prompts/<agent>.md file and invokes it with its native
178
- // slash-command syntax. Pi 0.84.1 rejects a bare -- terminator, so none is
179
- // included. A non-empty ResumeID selects Pi's exact session-id resume option.
182
+ // slash-command syntax only for a fresh launch. Resumed sessions receive the
183
+ // raw feedback prompt so Pi does not expand the template a second time. Pi
184
+ // 0.84.1 rejects a bare -- terminator, so none is included. A non-empty
185
+ // ResumeID selects Pi's exact session-id resume option.
180
186
  func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
181
187
  if err := validateAgentName(spec.Agent); err != nil {
182
188
  return runner.Command{}, err
@@ -215,7 +221,11 @@ func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
215
221
  if spec.ResumeID != "" {
216
222
  args = append(args, "--session-id", spec.ResumeID)
217
223
  }
218
- args = append(args, applyPromptTemplate(spec.Agent, spec.Prompt))
224
+ prompt := spec.Prompt
225
+ if spec.ResumeID == "" {
226
+ prompt = applyPromptTemplate(spec.Agent, prompt)
227
+ }
228
+ args = append(args, prompt)
219
229
  return runner.Command{
220
230
  Executable: "pi",
221
231
  Args: args,
@@ -148,7 +148,7 @@ func TestBuildCommandUsesNativePromptTemplate(t *testing.T) {
148
148
  if err != nil {
149
149
  t.Fatalf("BuildCommand(resume): %v", err)
150
150
  }
151
- want = []string{"--name", spec.Title, "--prompt-template", ".pi/prompts/coder.md", "--session-id", spec.ResumeID, "/coder " + spec.Prompt}
151
+ want = []string{"--name", spec.Title, "--prompt-template", ".pi/prompts/coder.md", "--session-id", spec.ResumeID, spec.Prompt}
152
152
  if !reflect.DeepEqual(cmd.Args, want) {
153
153
  t.Fatalf("resume Args = %#v, want %#v", cmd.Args, want)
154
154
  }
@@ -97,7 +97,7 @@ func TestPiPromptRetainsDefaultAgentLabel(t *testing.T) {
97
97
  }
98
98
  }
99
99
 
100
- func TestPiRenderPromptUsesNativeTemplateCommandForNamedAgent(t *testing.T) {
100
+ func TestPiRenderPromptUsesNativeTemplateCommandForInitialNamedAgent(t *testing.T) {
101
101
  h := newPiHarness(t)
102
102
  data := harness.PromptData{
103
103
  TaskSystem: "jira",
@@ -120,7 +120,7 @@ func TestPiRenderPromptUsesNativeTemplateCommandForNamedAgent(t *testing.T) {
120
120
  if err != nil {
121
121
  t.Fatalf("RenderPrompt(feedback): %v", err)
122
122
  }
123
- wantFeedback := "/coder New feedback was added to the comments section of your mailbox subtask PAY-234. Read it.\n\nfeedback nudge"
123
+ wantFeedback := "New feedback was added to the comments section of your mailbox subtask PAY-234. Read it.\n\nfeedback nudge"
124
124
  if feedback != wantFeedback {
125
125
  t.Fatalf("feedback prompt = %q, want %q", feedback, wantFeedback)
126
126
  }
@@ -0,0 +1,38 @@
1
+ package runner
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "os"
8
+ "os/exec"
9
+ "strings"
10
+ )
11
+
12
+ // CheckCleanCheckout verifies the ticket-scoped checkout before runner cleanup.
13
+ // A checkout that has already disappeared is an idempotent cleanup success;
14
+ // callers preserve their existing roll-forward behavior in that case.
15
+ func CheckCleanCheckout(ctx context.Context, ticket, checkout string) error {
16
+ if strings.TrimSpace(checkout) == "" {
17
+ return fmt.Errorf("cannot check Git cleanliness for ticket %q: checkout path is empty", ticket)
18
+ }
19
+ if _, err := os.Stat(checkout); err != nil {
20
+ if errors.Is(err, os.ErrNotExist) {
21
+ return nil
22
+ }
23
+ return fmt.Errorf("cannot check Git cleanliness for ticket %q at %q: %w", ticket, checkout, err)
24
+ }
25
+
26
+ out, err := exec.CommandContext(ctx, "git", "-C", checkout, "status", "--porcelain=v1", "--untracked-files=all").CombinedOutput()
27
+ if err != nil {
28
+ detail := strings.TrimSpace(string(out))
29
+ if detail != "" {
30
+ return fmt.Errorf("cannot check Git cleanliness for ticket %q at %q: %w: %s", ticket, checkout, err, detail)
31
+ }
32
+ return fmt.Errorf("cannot check Git cleanliness for ticket %q at %q: %w", ticket, checkout, err)
33
+ }
34
+ if len(out) != 0 {
35
+ return fmt.Errorf("ticket %q checkout is dirty; commit required before runner cleanup", ticket)
36
+ }
37
+ return nil
38
+ }
@@ -0,0 +1,84 @@
1
+ package runner
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "os/exec"
7
+ "path/filepath"
8
+ "strings"
9
+ "testing"
10
+ )
11
+
12
+ func TestCheckCleanCheckoutRejectsGitChanges(t *testing.T) {
13
+ cases := map[string]func(t *testing.T, repo string){
14
+ "untracked": func(t *testing.T, repo string) {
15
+ writeFile(t, filepath.Join(repo, "untracked.txt"), "new\n")
16
+ },
17
+ "unstaged": func(t *testing.T, repo string) {
18
+ writeFile(t, filepath.Join(repo, "tracked.txt"), "changed\n")
19
+ },
20
+ "staged": func(t *testing.T, repo string) {
21
+ writeFile(t, filepath.Join(repo, "tracked.txt"), "changed\n")
22
+ runGit(t, repo, "add", "tracked.txt")
23
+ },
24
+ }
25
+ for name, makeDirty := range cases {
26
+ t.Run(name, func(t *testing.T) {
27
+ repo := newCleanCheckout(t)
28
+ makeDirty(t, repo)
29
+ err := CheckCleanCheckout(context.Background(), "PAY-101", repo)
30
+ if err == nil || !strings.Contains(err.Error(), "PAY-101") || !strings.Contains(err.Error(), "commit required") {
31
+ t.Fatalf("CheckCleanCheckout error = %v, want ticket and commit-required message", err)
32
+ }
33
+ })
34
+ }
35
+ }
36
+
37
+ func TestCheckCleanCheckoutAcceptsCleanAndMissingCheckouts(t *testing.T) {
38
+ repo := newCleanCheckout(t)
39
+ if err := CheckCleanCheckout(context.Background(), "PAY-101", repo); err != nil {
40
+ t.Fatalf("clean checkout returned %v", err)
41
+ }
42
+ if err := CheckCleanCheckout(context.Background(), "PAY-101", filepath.Join(t.TempDir(), "removed")); err != nil {
43
+ t.Fatalf("missing checkout returned %v, want idempotent success", err)
44
+ }
45
+ }
46
+
47
+ func TestCheckCleanCheckoutPropagatesGitStatusFailure(t *testing.T) {
48
+ dir := t.TempDir()
49
+ err := CheckCleanCheckout(context.Background(), "PAY-101", dir)
50
+ if err == nil {
51
+ t.Fatal("non-Git checkout was treated as clean")
52
+ }
53
+ if strings.Contains(err.Error(), "commit required") {
54
+ t.Fatalf("Git status failure was reported as dirty state: %v", err)
55
+ }
56
+ }
57
+
58
+ func newCleanCheckout(t *testing.T) string {
59
+ t.Helper()
60
+ repo := t.TempDir()
61
+ runGit(t, repo, "init", "-q", "-b", "main")
62
+ runGit(t, repo, "config", "user.email", "relay-flow@example.invalid")
63
+ runGit(t, repo, "config", "user.name", "Relay Flow")
64
+ writeFile(t, filepath.Join(repo, "tracked.txt"), "clean\n")
65
+ runGit(t, repo, "add", "tracked.txt")
66
+ runGit(t, repo, "commit", "-qm", "initial")
67
+ return repo
68
+ }
69
+
70
+ func writeFile(t *testing.T, path, contents string) {
71
+ t.Helper()
72
+ if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
73
+ t.Fatal(err)
74
+ }
75
+ }
76
+
77
+ func runGit(t *testing.T, repo string, args ...string) {
78
+ t.Helper()
79
+ cmd := exec.Command("git", args...)
80
+ cmd.Dir = repo
81
+ if output, err := cmd.CombinedOutput(); err != nil {
82
+ t.Fatalf("git %v: %v: %s", args, err, output)
83
+ }
84
+ }
@@ -282,24 +282,24 @@ func (*adapter) SetEnvironmentStatus(context.Context, runner.Environment, string
282
282
  // ticketWorkspace resolves the ticket's currently open worktree workspace
283
283
  // without creating anything. found is false when the repository, the ticket
284
284
  // checkout, or its workspace is absent, which lets cleanup roll forward.
285
- func (a *adapter) ticketWorkspace(ctx context.Context, spec runner.RunSpec) (string, bool, error) {
285
+ func (a *adapter) ticketWorkspace(ctx context.Context, spec runner.RunSpec) (string, string, bool, error) {
286
286
  repoPath := normalizePath(spec.RepoPath)
287
287
  if repoPath == "" {
288
- return "", false, nil
288
+ return "", "", false, nil
289
289
  }
290
290
  listing, err := a.cli.WorktreeList(ctx, repoPath)
291
291
  if err != nil {
292
292
  if errors.Is(err, herdrcli.ErrNotGitWorktree) || errors.Is(err, herdrcli.ErrWorktreeNotFound) {
293
- return "", false, nil
293
+ return "", "", false, nil
294
294
  }
295
- return "", false, err
295
+ return "", "", false, err
296
296
  }
297
297
  for _, worktree := range listing.Worktrees {
298
298
  if worktree.Branch == spec.TicketKey && worktree.OpenWorkspaceID != "" {
299
- return worktree.OpenWorkspaceID, true, nil
299
+ return worktree.OpenWorkspaceID, normalizePath(worktree.Path), true, nil
300
300
  }
301
301
  }
302
- return "", false, nil
302
+ return "", "", false, nil
303
303
  }
304
304
 
305
305
  // --- Terminals ---
@@ -307,7 +307,7 @@ func (a *adapter) ticketWorkspace(ctx context.Context, spec runner.RunSpec) (str
307
307
  // DiscoverTerminal finds an existing live pane by its stable title during
308
308
  // explicit projection recovery. It never creates a workspace or pane.
309
309
  func (a *adapter) DiscoverTerminal(ctx context.Context, spec runner.RunSpec, title string) (runner.Terminal, bool, error) {
310
- workspaceID, found, err := a.ticketWorkspace(ctx, spec)
310
+ workspaceID, _, found, err := a.ticketWorkspace(ctx, spec)
311
311
  if err != nil || !found {
312
312
  return runner.Terminal{}, false, err
313
313
  }
@@ -549,7 +549,7 @@ func (a *adapter) CloseTerminal(ctx context.Context, terminal runner.Terminal) e
549
549
  func (a *adapter) CloseTerminals(ctx context.Context, spec runner.RunSpec) error {
550
550
  attrs := []any{"ticket", spec.TicketKey, "runID", string(spec.RunID)}
551
551
  logCall("close-terminals", attrs...)
552
- workspaceID, found, err := a.ticketWorkspace(ctx, spec)
552
+ workspaceID, _, found, err := a.ticketWorkspace(ctx, spec)
553
553
  if err != nil {
554
554
  logOutcome("close-terminals", "error", attrs...)
555
555
  return err
@@ -606,15 +606,29 @@ func (a *adapter) CloseTerminals(ctx context.Context, spec runner.RunSpec) error
606
606
 
607
607
  // CleanupRun releases the runner-owned resources for the run: node panes and
608
608
  // the ticket workspace. The Git worktree, its branch, and its files are
609
- // deliberately preserved; a later run reopens the same checkout.
609
+ // deliberately preserved; a later run reopens the same checkout. The ticket
610
+ // checkout must be clean before the workspace is closed.
610
611
  func (a *adapter) CleanupRun(ctx context.Context, spec runner.RunSpec) error {
611
612
  attrs := []any{"ticket", spec.TicketKey, "runID", string(spec.RunID)}
612
613
  logCall("cleanup-run", attrs...)
614
+ workspaceID, checkout, found, err := a.ticketWorkspace(ctx, spec)
615
+ if err != nil {
616
+ logOutcome("cleanup-run", "error", attrs...)
617
+ return err
618
+ }
619
+ if !found {
620
+ logOutcome("cleanup-run", "no-environment", attrs...)
621
+ return nil
622
+ }
623
+ if err := runner.CheckCleanCheckout(ctx, spec.TicketKey, checkout); err != nil {
624
+ logOutcome("cleanup-run", "error", attrs...)
625
+ return err
626
+ }
613
627
  if err := a.CloseTerminals(ctx, spec); err != nil {
614
628
  logOutcome("cleanup-run", "error", attrs...)
615
629
  return err
616
630
  }
617
- workspaceID, found, err := a.ticketWorkspace(ctx, spec)
631
+ workspaceID, _, found, err = a.ticketWorkspace(ctx, spec)
618
632
  if err != nil {
619
633
  logOutcome("cleanup-run", "error", attrs...)
620
634
  return err
@@ -739,6 +739,72 @@ func TestCleanupRunClosesTicketWorkspaceAndKeepsWorktree(t *testing.T) {
739
739
  }
740
740
  }
741
741
 
742
+ func TestCleanupRunBlocksDirtyCheckoutBeforeClosingHerdrResources(t *testing.T) {
743
+ repo := newGitRepo(t, "main")
744
+ if err := os.WriteFile(filepath.Join(repo, "dirty.txt"), []byte("uncommitted\n"), 0o644); err != nil {
745
+ t.Fatal(err)
746
+ }
747
+ cli := cleanupClientAt(repo)
748
+ spec := runSpec()
749
+ spec.RepoPath = repo
750
+ if err := newAdapter(cli).CleanupRun(context.Background(), spec); err == nil || !strings.Contains(err.Error(), "commit required") {
751
+ t.Fatalf("CleanupRun error = %v, want commit-required dirty-check error", err)
752
+ }
753
+ if len(cli.closedPanes) != 0 || len(cli.closedWorkspaces) != 0 {
754
+ t.Fatalf("dirty cleanup touched Herdr resources: panes=%v workspaces=%v", cli.closedPanes, cli.closedWorkspaces)
755
+ }
756
+ }
757
+
758
+ func TestCleanupRunAllowsCleanCheckoutAndLaterRetry(t *testing.T) {
759
+ repo := newGitRepo(t, "main")
760
+ dirty := filepath.Join(repo, "dirty.txt")
761
+ if err := os.WriteFile(dirty, []byte("uncommitted\n"), 0o644); err != nil {
762
+ t.Fatal(err)
763
+ }
764
+ cli := cleanupClientAt(repo)
765
+ spec := runSpec()
766
+ spec.RepoPath = repo
767
+ a := newAdapter(cli)
768
+ if err := a.CleanupRun(context.Background(), spec); err == nil {
769
+ t.Fatal("dirty CleanupRun succeeded")
770
+ }
771
+ if err := os.Remove(dirty); err != nil {
772
+ t.Fatal(err)
773
+ }
774
+ if err := a.CleanupRun(context.Background(), spec); err != nil {
775
+ t.Fatalf("clean retry CleanupRun = %v", err)
776
+ }
777
+ if len(cli.closedPanes) != 1 || len(cli.closedWorkspaces) != 1 {
778
+ t.Fatalf("clean retry resources: panes=%v workspaces=%v", cli.closedPanes, cli.closedWorkspaces)
779
+ }
780
+ }
781
+
782
+ func TestCleanupRunPropagatesGitStatusFailureBeforeClosingHerdrResources(t *testing.T) {
783
+ repo := t.TempDir()
784
+ cli := cleanupClientAt(repo)
785
+ spec := runSpec()
786
+ spec.RepoPath = repo
787
+ if err := newAdapter(cli).CleanupRun(context.Background(), spec); err == nil {
788
+ t.Fatal("CleanupRun treated Git status failure as clean")
789
+ }
790
+ if len(cli.closedPanes) != 0 || len(cli.closedWorkspaces) != 0 {
791
+ t.Fatalf("Git status failure touched Herdr resources: panes=%v workspaces=%v", cli.closedPanes, cli.closedWorkspaces)
792
+ }
793
+ }
794
+
795
+ func cleanupClientAt(repo string) *fakeClient {
796
+ return &fakeClient{
797
+ listing: herdrcli.WorktreeListing{
798
+ Source: herdrcli.WorktreeSource{RepoName: "payments", RepoRoot: repo, SourceCheckoutPath: repo},
799
+ Worktrees: []herdrcli.Worktree{
800
+ {Path: repo, Branch: "PAY-101", IsLinked: true, OpenWorkspaceID: "w2"},
801
+ },
802
+ },
803
+ tabs: []herdrcli.Tab{{ID: "w2:t2", WorkspaceID: "w2", Label: "PAY-101:coding"}},
804
+ panes: []herdrcli.Pane{{ID: "w2:p2", WorkspaceID: "w2", TabID: "w2:t2", Label: "PAY-101:coding"}},
805
+ }
806
+ }
807
+
742
808
  func TestCleanupRollsForwardWhenTicketWorktreeIsGone(t *testing.T) {
743
809
  cases := map[string]*fakeClient{
744
810
  "repository is not a git work tree": {listingErr: herdrcli.ErrNotGitWorktree},
@@ -457,14 +457,28 @@ func (a *adapter) CloseTerminals(ctx context.Context, spec runner.RunSpec) error
457
457
  }
458
458
 
459
459
  // CleanupRun removes all runner-owned run resources: terminals, then the
460
- // ticket worktree itself.
460
+ // ticket worktree itself. The ticket checkout must be clean before either
461
+ // resource is released.
461
462
  func (a *adapter) CleanupRun(ctx context.Context, spec runner.RunSpec) error {
462
463
  slog.Debug("orca call", "op", "cleanup-run", "ticket", spec.TicketKey, "runID", string(spec.RunID))
464
+ env, ok, err := a.findEnvironment(ctx, spec)
465
+ if err != nil || !ok {
466
+ if err != nil {
467
+ slog.Info("orca outcome", "op", "cleanup-run", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))
468
+ } else {
469
+ slog.Info("orca outcome", "op", "cleanup-run", "ticket", spec.TicketKey, "result", "no-environment")
470
+ }
471
+ return err
472
+ }
473
+ if err := runner.CheckCleanCheckout(ctx, spec.TicketKey, env.Path); err != nil {
474
+ slog.Info("orca outcome", "op", "cleanup-run", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))
475
+ return err
476
+ }
463
477
  if err := a.CloseTerminals(ctx, spec); err != nil {
464
478
  slog.Info("orca outcome", "op", "cleanup-run", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))
465
479
  return err
466
480
  }
467
- env, ok, err := a.findEnvironment(ctx, spec)
481
+ env, ok, err = a.findEnvironment(ctx, spec)
468
482
  if err != nil || !ok {
469
483
  if err != nil {
470
484
  slog.Info("orca outcome", "op", "cleanup-run", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))