relay-flow 0.2.11-alpha → 0.3.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.
package/README.md CHANGED
@@ -24,13 +24,13 @@ same `relay-flow-plugin` package with host-specific entrypoints.
24
24
  **OpenCode**
25
25
 
26
26
  ```sh
27
- opencode plugin relay-flow-plugin@0.2.11-alpha
27
+ opencode plugin relay-flow-plugin@0.3.1-alpha
28
28
  ```
29
29
 
30
30
  **Pi**
31
31
 
32
32
  ```sh
33
- pi install npm:relay-flow-plugin@0.2.11-alpha
33
+ pi install npm:relay-flow-plugin@0.3.1-alpha
34
34
  ```
35
35
 
36
36
  Pi loads the package's `pi.ts` extension from its manifest. Do not add
@@ -160,7 +160,7 @@ OpenCode plugin configuration uses both entrypoints. The server entrypoint is li
160
160
  ```json
161
161
  {
162
162
  "$schema": "https://opencode.ai/config.json",
163
- "plugin": ["relay-flow-plugin@0.2.11-alpha"]
163
+ "plugin": ["relay-flow-plugin@0.3.1-alpha"]
164
164
  }
165
165
  ```
166
166
 
@@ -169,7 +169,7 @@ The native HITL approval entrypoint is listed in `.opencode/tui.json`:
169
169
  ```json
170
170
  {
171
171
  "$schema": "https://opencode.ai/tui.json",
172
- "plugin": ["relay-flow-plugin@0.2.11-alpha"]
172
+ "plugin": ["relay-flow-plugin@0.3.1-alpha"]
173
173
  }
174
174
  ```
175
175
 
@@ -185,7 +185,7 @@ Pi plugin: install the same published package manually in Pi's global package
185
185
  settings before starting a Pi harness session:
186
186
 
187
187
  ```sh
188
- pi install npm:relay-flow-plugin@0.2.11-alpha
188
+ pi install npm:relay-flow-plugin@0.3.1-alpha
189
189
  ```
190
190
 
191
191
  Relay-flow does not install or configure the package automatically. Pi resolves
@@ -629,7 +629,7 @@ EXPECTED RESULT: ...
629
629
 
630
630
  The labels above are fixed; configurable templates do not change the parsed report contract. The plugin submits one `report` object containing both lower-camel `summary` and `feedback` objects. Relay-flow validates that complete shape once, renders `summaryReport` through the task system's summary-comment template on the current mailbox, and renders `feedbackReport` through its feedback-comment template on only the selected next mailbox. `None` is the literal marker for an intentionally empty section. When `NEXT STEP` is `end`, every FEEDBACK field must be `None` and no feedback comment is written.
631
631
 
632
- The plugin delivers `{runId, node, reportId, report}` as one JSON object via `relay-flow report` stdin with the shared backoff (initial 2s, factor 2, jitter 0.2, max 5m) until acknowledged. It derives `reportId` from the harness session/message identity. Duplicate/stale reports are acked safely with no repeated graph effects. Invalid agent output is nudged; invalid or missing HITL output stays silent, while a valid HITL report opens the native TUI approval dialog. Relay-flow HITL approval does not use OpenCode's Question tool.
632
+ The plugin delivers `{runId, node, reportId, report}` as one JSON object via `relay-flow report` stdin with the shared backoff (initial 2s, factor 2, jitter 0.2, max 5m) until acknowledged. It derives `reportId` from the harness session/message identity. Duplicate/stale reports are acked safely with no repeated graph effects. Invalid agent output is nudged; ordinary, missing, or empty HITL output stays silent, while partial report-shaped HITL output is corrected and a valid HITL report opens the native TUI approval dialog. Relay-flow HITL approval does not use OpenCode's Question tool.
633
633
 
634
634
  ---
635
635
 
@@ -180,6 +180,8 @@ Run:
180
180
  relay-flow run restart --ticket <key>
181
181
  relay-flow run cancel --ticket <key>
182
182
 
183
+ Use relay-flow <command> --help for command-specific details.
184
+
183
185
  Examples:
184
186
  relay-flow workflow list --json
185
187
  relay-flow run get --ticket PAY-101
@@ -235,7 +237,14 @@ func printScopedHelp(args []string, w io.Writer) int {
235
237
  case "init":
236
238
  fmt.Fprintln(w, "Usage: relay-flow init [flags]")
237
239
  fmt.Fprintln(w, "\nInitialize relay-flow and its durable state.")
238
- fmt.Fprintln(w, "Flags: --force, --task-plugin, --runner-plugin, --harness-plugin, --executor-plugin")
240
+ fmt.Fprintln(w, "Flags:")
241
+ fmt.Fprintln(w, " --task-plugin <name> Task-system plugin (required with runner and harness plugin flags).")
242
+ fmt.Fprintln(w, " --runner-plugin <name> Runner plugin (required with task and harness plugin flags).")
243
+ fmt.Fprintln(w, " --harness-plugin <name> Harness plugin (required with task and runner plugin flags).")
244
+ fmt.Fprintln(w, " --executor-plugin <name> Durable executor: goworkflows or temporal.")
245
+ fmt.Fprintln(w, " --temporal-address <host:port> Temporal server address; applies only to --executor-plugin temporal (optional, defaults to localhost:7233).")
246
+ fmt.Fprintln(w, " --temporal-namespace <name> Temporal namespace/team name; applies only to --executor-plugin temporal (required).")
247
+ fmt.Fprintln(w, " --force Update plugin selections while preserving existing durable state.")
239
248
  fmt.Fprintln(w, "Example: relay-flow init --task-plugin jira --runner-plugin orca --harness-plugin opencode")
240
249
  case "serve":
241
250
  fmt.Fprintln(w, "Usage: relay-flow serve [--recover] [--debug] [--background]")
@@ -251,11 +260,15 @@ func printScopedHelp(args []string, w io.Writer) int {
251
260
  fmt.Fprintln(w, "Example: relay-flow report < report.json")
252
261
  case "task":
253
262
  fmt.Fprintln(w, "Usage: relay-flow task <command>")
254
- fmt.Fprintln(w, "\nCommands: auth")
263
+ fmt.Fprintln(w, "\nRun task-system commands through the configured task plugin.")
264
+ fmt.Fprintln(w, "Commands:")
265
+ fmt.Fprintln(w, " auth Authenticate the configured task-system plugin.")
266
+ fmt.Fprintln(w, "Use relay-flow task <subcommand> --help for command-specific details.")
255
267
  fmt.Fprintln(w, "Example: relay-flow task auth")
256
268
  case "task auth":
257
269
  fmt.Fprintln(w, "Usage: relay-flow task auth [task-plugin options]")
258
- fmt.Fprintln(w, "\nDelegate authentication to the configured task-system plugin.")
270
+ fmt.Fprintln(w, "\nAuthenticate the configured task-system plugin.")
271
+ fmt.Fprintln(w, "Arguments: task-plugin options are passed through unchanged; relay-flow adds no flags.")
259
272
  fmt.Fprintln(w, "Example: relay-flow task auth")
260
273
  case "runtime-register":
261
274
  fmt.Fprintln(w, "Usage: relay-flow runtime-register < session.json")
@@ -267,61 +280,143 @@ func printScopedHelp(args []string, w io.Writer) int {
267
280
  fmt.Fprintln(w, "Example: relay-flow version")
268
281
  case "workflow":
269
282
  fmt.Fprintln(w, "Usage: relay-flow workflow <command>")
270
- fmt.Fprintln(w, "\nCommands: submit, remove, list, get")
271
- fmt.Fprintln(w, "Use relay-flow workflow <command> --help for command-specific flags.")
272
- fmt.Fprintln(w, "Example: relay-flow workflow list --json")
283
+ fmt.Fprintln(w, "\nManage validated workflow definitions and inspect their runs.")
284
+ fmt.Fprintln(w, "Commands:")
285
+ fmt.Fprintln(w, " submit Create or replace a workflow definition.")
286
+ fmt.Fprintln(w, " Usage: relay-flow workflow submit --file <path>")
287
+ fmt.Fprintln(w, " Example: relay-flow workflow submit --file workflow.yaml")
288
+ fmt.Fprintln(w, " remove Remove a workflow with no active runs.")
289
+ fmt.Fprintln(w, " Usage: relay-flow workflow remove --name <name>")
290
+ fmt.Fprintln(w, " Example: relay-flow workflow remove --name basicFlow")
291
+ fmt.Fprintln(w, " list List configured workflows and latest execution summaries.")
292
+ fmt.Fprintln(w, " Usage: relay-flow workflow list [--json] [--no-color]")
293
+ fmt.Fprintln(w, " Example: relay-flow workflow list --json")
294
+ fmt.Fprintln(w, " get Show a workflow graph and recent runs.")
295
+ fmt.Fprintln(w, " Usage: relay-flow workflow get --name <name> [--json] [--no-color]")
296
+ fmt.Fprintln(w, " Example: relay-flow workflow get --name basicFlow")
297
+ fmt.Fprintln(w, "Use relay-flow workflow <subcommand> --help for flag details.")
273
298
  case "workflow submit":
274
299
  fmt.Fprintln(w, "Usage: relay-flow workflow submit --file <path>")
275
300
  fmt.Fprintln(w, "\nCreate or replace a validated workflow definition.")
301
+ fmt.Fprintln(w, "Flags:")
302
+ fmt.Fprintln(w, " --file <path> Workflow YAML file to submit (required).")
276
303
  fmt.Fprintln(w, "Example: relay-flow workflow submit --file workflow.yaml")
277
304
  case "workflow remove":
278
305
  fmt.Fprintln(w, "Usage: relay-flow workflow remove --name <name>")
279
306
  fmt.Fprintln(w, "\nRemove a workflow with no active runs.")
307
+ fmt.Fprintln(w, "Flags:")
308
+ fmt.Fprintln(w, " --name <name> Workflow name (required).")
280
309
  fmt.Fprintln(w, "Example: relay-flow workflow remove --name basicFlow")
281
310
  case "workflow list":
282
311
  fmt.Fprintln(w, "Usage: relay-flow workflow list [--json] [--no-color]")
283
312
  fmt.Fprintln(w, "\nList configured workflows and their latest execution summaries.")
313
+ fmt.Fprintln(w, "Flags:")
314
+ fmt.Fprintln(w, " --json Print stable JSON instead of the human-readable table.")
315
+ fmt.Fprintln(w, " --no-color Disable terminal color decoration.")
284
316
  fmt.Fprintln(w, "Example: relay-flow workflow list --json")
285
317
  case "workflow get":
286
318
  fmt.Fprintln(w, "Usage: relay-flow workflow get --name <name> [--json] [--no-color]")
287
- fmt.Fprintln(w, "\nShow validated static graph and recent runs. Use --json for automation.")
319
+ fmt.Fprintln(w, "\nShow a workflow's validated static graph and recent runs.")
320
+ fmt.Fprintln(w, "Flags:")
321
+ fmt.Fprintln(w, " --name <name> Workflow name (required).")
322
+ fmt.Fprintln(w, " --json Print stable JSON instead of the human-readable detail view.")
323
+ fmt.Fprintln(w, " --no-color Disable terminal color decoration.")
288
324
  fmt.Fprintln(w, "Example: relay-flow workflow get --name basicFlow")
289
325
  case "repo":
290
326
  fmt.Fprintln(w, "Usage: relay-flow repo <command>")
291
- fmt.Fprintln(w, "\nCommands: register, remove, list, get")
292
- fmt.Fprintln(w, "Use relay-flow repo <command> --help for command-specific flags.")
293
- fmt.Fprintln(w, "Example: relay-flow repo list")
327
+ fmt.Fprintln(w, "\nManage registered runner repositories and task-system configuration.")
328
+ fmt.Fprintln(w, "Commands:")
329
+ fmt.Fprintln(w, " register Register a runner repository and its task-system configuration.")
330
+ fmt.Fprintln(w, " Usage: relay-flow repo register [--name <name>] [--path <path>] [--set key=value ...]")
331
+ fmt.Fprintln(w, " Example (Beads): relay-flow repo register --name payments --path /work/payments --set beadsDir=/work/payments/.beads")
332
+ fmt.Fprintln(w, " remove Remove a registered repository.")
333
+ fmt.Fprintln(w, " Usage: relay-flow repo remove --name <name>")
334
+ fmt.Fprintln(w, " Example: relay-flow repo remove --name payments")
335
+ fmt.Fprintln(w, " list List registered repositories.")
336
+ fmt.Fprintln(w, " Usage: relay-flow repo list")
337
+ fmt.Fprintln(w, " Example: relay-flow repo list")
338
+ fmt.Fprintln(w, " get Show one registered repository as JSON.")
339
+ fmt.Fprintln(w, " Usage: relay-flow repo get --name <name>")
340
+ fmt.Fprintln(w, " Example: relay-flow repo get --name payments")
341
+ fmt.Fprintln(w, "Use relay-flow repo <subcommand> --help for flag details.")
294
342
  case "repo register":
295
- fmt.Fprintln(w, "Usage: relay-flow repo register [--name <name> --path <path> --set key=value]")
343
+ fmt.Fprintln(w, "Usage: relay-flow repo register [--name <name>] [--path <path>] [--set key=value ...]")
296
344
  fmt.Fprintln(w, "\nRegister a runner repository and task-system configuration.")
297
- fmt.Fprintln(w, "Example: relay-flow repo register --name payments --path /work/payments")
298
- case "repo remove", "repo get":
299
- fmt.Fprintf(w, "Usage: relay-flow %s --name <name>\n", name)
300
- fmt.Fprintf(w, "Example: relay-flow %s --name payments\n", name)
345
+ fmt.Fprintln(w, "Flags:")
346
+ fmt.Fprintln(w, " --name <name> Repository name. Optional for interactive registration; required in flagged/non-interactive mode.")
347
+ fmt.Fprintln(w, " --path <path> Local repository path. Optional for interactive registration; required in flagged/non-interactive mode.")
348
+ fmt.Fprintln(w, " --set key=value Optional, repeatable task-system configuration override. Key and value must be non-empty; duplicate keys are rejected.")
349
+ fmt.Fprintln(w, "\nInteractive mode: omit --name, --path, and --set; a TTY is required.")
350
+ fmt.Fprintln(w, "Flagged mode: --name and --path are required. Repeat --set once per task-system key; the selected plugin validates keys and values, and derived keys cannot be overridden.")
351
+ fmt.Fprintln(w, "Example (Beads): relay-flow repo register --name payments --path /work/payments --set beadsDir=/work/payments/.beads")
352
+ case "repo remove":
353
+ fmt.Fprintln(w, "Usage: relay-flow repo remove --name <name>")
354
+ fmt.Fprintln(w, "\nRemove a registered repository.")
355
+ fmt.Fprintln(w, "Flags:")
356
+ fmt.Fprintln(w, " --name <name> Registered repository name (required).")
357
+ fmt.Fprintln(w, "No other flags are supported.")
358
+ fmt.Fprintln(w, "Example: relay-flow repo remove --name payments")
301
359
  case "repo list":
302
360
  fmt.Fprintln(w, "Usage: relay-flow repo list")
303
361
  fmt.Fprintln(w, "\nList registered repositories.")
362
+ fmt.Fprintln(w, "Flags: none.")
304
363
  fmt.Fprintln(w, "Example: relay-flow repo list")
364
+ case "repo get":
365
+ fmt.Fprintln(w, "Usage: relay-flow repo get --name <name>")
366
+ fmt.Fprintln(w, "\nShow one registered repository and its task-system configuration as JSON.")
367
+ fmt.Fprintln(w, "Flags:")
368
+ fmt.Fprintln(w, " --name <name> Registered repository name (required).")
369
+ fmt.Fprintln(w, "No other flags are supported.")
370
+ fmt.Fprintln(w, "Example: relay-flow repo get --name payments")
305
371
  case "run":
306
372
  fmt.Fprintln(w, "Usage: relay-flow run <command>")
307
- fmt.Fprintln(w, "\nCommands: list, get, restart, cancel")
308
- fmt.Fprintln(w, "Use relay-flow run <command> --help for command-specific flags.")
309
- fmt.Fprintln(w, "Example: relay-flow run get --ticket PAY-101")
373
+ fmt.Fprintln(w, "\nInspect and control durable ticket runs.")
374
+ fmt.Fprintln(w, "Commands:")
375
+ fmt.Fprintln(w, " list List runs, optionally filtered by repository, workflow, ticket, or active state.")
376
+ fmt.Fprintln(w, " Usage: relay-flow run list [--repo <name>] [--workflow <name>] [--ticket <key>] [--active] [--json] [--no-color]")
377
+ fmt.Fprintln(w, " Example: relay-flow run list --active --json")
378
+ fmt.Fprintln(w, " get Show one run's execution detail and actual step visits.")
379
+ fmt.Fprintln(w, " Usage: relay-flow run get --ticket <key> [--json] [--no-color]")
380
+ fmt.Fprintln(w, " Example: relay-flow run get --ticket PAY-101")
381
+ fmt.Fprintln(w, " restart Start a fresh attempt for a canceled run.")
382
+ fmt.Fprintln(w, " Usage: relay-flow run restart --ticket <key>")
383
+ fmt.Fprintln(w, " Example: relay-flow run restart --ticket PAY-101")
384
+ fmt.Fprintln(w, " cancel Permanently cancel the current execution attempt.")
385
+ fmt.Fprintln(w, " Usage: relay-flow run cancel --ticket <key> [--reason <text>]")
386
+ fmt.Fprintln(w, " Example: relay-flow run cancel --ticket PAY-101 --reason "+`"operator request"`)
387
+ fmt.Fprintln(w, "Use relay-flow run <subcommand> --help for flag details.")
310
388
  case "run list":
311
389
  fmt.Fprintln(w, "Usage: relay-flow run list [--repo <name>] [--workflow <name>] [--ticket <key>] [--active] [--json] [--no-color]")
312
- fmt.Fprintln(w, "\nList durable runs. Use --json for automation.")
390
+ fmt.Fprintln(w, "\nList durable runs, with optional filters.")
391
+ fmt.Fprintln(w, "Flags:")
392
+ fmt.Fprintln(w, " --repo <name> Filter by registered repository name.")
393
+ fmt.Fprintln(w, " --workflow <name> Filter by workflow name.")
394
+ fmt.Fprintln(w, " --ticket <key> Filter by task ticket key.")
395
+ fmt.Fprintln(w, " --active Show only active runs.")
396
+ fmt.Fprintln(w, " --json Print stable JSON instead of the human-readable table.")
397
+ fmt.Fprintln(w, " --no-color Disable terminal color decoration.")
313
398
  fmt.Fprintln(w, "Example: relay-flow run list --active --json")
314
399
  case "run get":
315
400
  fmt.Fprintln(w, "Usage: relay-flow run get --ticket <key> [--json] [--no-color]")
316
401
  fmt.Fprintln(w, "\nShow an Argo-shaped execution detail view and actual step visits.")
402
+ fmt.Fprintln(w, "Flags:")
403
+ fmt.Fprintln(w, " --ticket <key> Task ticket key (required).")
404
+ fmt.Fprintln(w, " --json Print stable JSON instead of the human-readable detail view.")
405
+ fmt.Fprintln(w, " --no-color Disable terminal color decoration.")
317
406
  fmt.Fprintln(w, "Example: relay-flow run get --ticket PAY-101")
318
407
  case "run restart":
319
408
  fmt.Fprintln(w, "Usage: relay-flow run restart --ticket <key>")
320
409
  fmt.Fprintln(w, "\nStart a fresh attempt for a canceled run.")
410
+ fmt.Fprintln(w, "Flags:")
411
+ fmt.Fprintln(w, " --ticket <key> Task ticket key (required).")
412
+ fmt.Fprintln(w, "No other flags are supported.")
321
413
  fmt.Fprintln(w, "Example: relay-flow run restart --ticket PAY-101")
322
414
  case "run cancel":
323
415
  fmt.Fprintln(w, "Usage: relay-flow run cancel --ticket <key> [--reason <text>]")
324
416
  fmt.Fprintln(w, "\nPermanently cancel the current execution attempt.")
417
+ fmt.Fprintln(w, "Flags:")
418
+ fmt.Fprintln(w, " --ticket <key> Task ticket key (required).")
419
+ fmt.Fprintln(w, " --reason <text> Optional cancellation reason recorded with the request.")
325
420
  fmt.Fprintln(w, "Example: relay-flow run cancel --ticket PAY-101 --reason "+`"operator request"`)
326
421
  default:
327
422
  fmt.Fprintf(os.Stderr, "unknown help target: %s\n", name)
@@ -4,6 +4,7 @@ import (
4
4
  "bytes"
5
5
  "io"
6
6
  "os"
7
+ "path/filepath"
7
8
  "strings"
8
9
  "testing"
9
10
  "time"
@@ -43,9 +44,10 @@ func TestRunDetailRendererKeepsPendingAndNestedVisitOrder(t *testing.T) {
43
44
  func TestScopedHelpCoversAllCommandLevels(t *testing.T) {
44
45
  for _, args := range [][]string{
45
46
  {"--help"}, {"workflow", "--help"}, {"workflow", "list", "--help"},
46
- {"repo", "--help"}, {"repo", "register", "--help"}, {"run", "--help"},
47
+ {"repo", "--help"}, {"repo", "register", "--help"}, {"repo", "remove", "--help"},
48
+ {"repo", "get", "--help"}, {"repo", "list", "--help"}, {"run", "--help"},
47
49
  {"run", "get", "--help"}, {"task", "--help"}, {"task", "auth", "--help"},
48
- {"serve", "--help"}, {"runtime-register", "--help"}, {"version", "--help"},
50
+ {"init", "--help"}, {"serve", "--help"}, {"runtime-register", "--help"}, {"version", "--help"},
49
51
  } {
50
52
  var out bytes.Buffer
51
53
  if code := printScopedHelp(args, &out); code != exitOK {
@@ -54,16 +56,126 @@ func TestScopedHelpCoversAllCommandLevels(t *testing.T) {
54
56
  if out.Len() == 0 {
55
57
  t.Fatalf("help %v was empty", args)
56
58
  }
57
- if !strings.Contains(out.String(), "Example:") {
59
+ if !strings.Contains(out.String(), "Example") {
58
60
  t.Fatalf("help %v missing example: %q", args, out.String())
59
61
  }
60
62
  }
61
- var example bytes.Buffer
62
- if code := printScopedHelp([]string{"run", "get", "--help"}, &example); code != exitOK || !strings.Contains(example.String(), "Example:") {
63
- t.Fatalf("run get help missing example: %q", example.String())
63
+ root := scopedHelp(t, "--help")
64
+ if !strings.Contains(root, "Use relay-flow <command> --help for command-specific details.") {
65
+ t.Fatalf("root help missing scoped-help guidance:\n%s", root)
64
66
  }
65
67
  }
66
68
 
69
+ func TestInitHelpDocumentsTemporalFlags(t *testing.T) {
70
+ text := scopedHelp(t, "init", "--help")
71
+ for _, want := range []string{
72
+ "--executor-plugin <name>",
73
+ "--temporal-address <host:port>",
74
+ "--temporal-namespace <name>",
75
+ "applies only to --executor-plugin temporal",
76
+ } {
77
+ if !strings.Contains(text, want) {
78
+ t.Fatalf("init help missing %q:\n%s", want, text)
79
+ }
80
+ }
81
+ }
82
+
83
+ func TestRepositoryHelpDocumentsGroupAndEverySubcommand(t *testing.T) {
84
+ group := scopedHelp(t, "repo", "--help")
85
+ for _, want := range []string{
86
+ "Manage registered runner repositories and task-system configuration.",
87
+ "register", "remove", "list", "get",
88
+ "Usage: relay-flow repo register", "Usage: relay-flow repo remove --name <name>",
89
+ "Usage: relay-flow repo list", "Usage: relay-flow repo get --name <name>",
90
+ "Example (Beads):", "beadsDir=/work/payments/.beads",
91
+ "Use relay-flow repo <subcommand> --help for flag details.",
92
+ } {
93
+ if !strings.Contains(group, want) {
94
+ t.Fatalf("repo group help missing %q:\n%s", want, group)
95
+ }
96
+ }
97
+
98
+ register := scopedHelp(t, "repo", "register", "--help")
99
+ for _, want := range []string{
100
+ "Register a runner repository and task-system configuration.",
101
+ "--name <name>", "--path <path>", "--set key=value",
102
+ "Optional for interactive registration", "required in flagged/non-interactive mode",
103
+ "Interactive mode: omit --name, --path, and --set", "Repeat --set",
104
+ "duplicate keys are rejected", "derived keys cannot be overridden",
105
+ "Example (Beads):", "beadsDir=/work/payments/.beads",
106
+ } {
107
+ if !strings.Contains(register, want) {
108
+ t.Fatalf("repo register help missing %q:\n%s", want, register)
109
+ }
110
+ }
111
+ if strings.Contains(register, "component=backend") {
112
+ t.Fatalf("repo register help advertises a derived Jira component override:\n%s", register)
113
+ }
114
+
115
+ for _, tc := range []struct {
116
+ name string
117
+ want []string
118
+ }{
119
+ {name: "remove", want: []string{"Remove a registered repository.", "--name <name>", "required", "No other flags are supported.", "Example:"}},
120
+ {name: "list", want: []string{"List registered repositories.", "Flags: none.", "Example:"}},
121
+ {name: "get", want: []string{"Show one registered repository", "--name <name>", "required", "No other flags are supported.", "Example:"}},
122
+ } {
123
+ text := scopedHelp(t, "repo", tc.name, "--help")
124
+ for _, want := range tc.want {
125
+ if !strings.Contains(text, want) {
126
+ t.Fatalf("repo %s help missing %q:\n%s", tc.name, want, text)
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ func TestWorkflowRunAndTaskGroupsDescribeTheirSubcommands(t *testing.T) {
133
+ for _, tc := range []struct {
134
+ args []string
135
+ want []string
136
+ }{
137
+ {args: []string{"workflow", "--help"}, want: []string{"Manage validated workflow definitions", "workflow submit --file <path>", "workflow remove --name <name>", "workflow list [--json]", "workflow get --name <name>", "<subcommand> --help"}},
138
+ {args: []string{"run", "--help"}, want: []string{"Inspect and control durable ticket runs.", "run list", "run get", "run restart", "run cancel", "--reason <text>", "<subcommand> --help"}},
139
+ {args: []string{"task", "--help"}, want: []string{"Run task-system commands", "auth", "task <subcommand> --help"}},
140
+ {args: []string{"workflow", "submit", "--help"}, want: []string{"--file <path>", "required", "Example:"}},
141
+ {args: []string{"run", "list", "--help"}, want: []string{"--repo <name>", "--workflow <name>", "--ticket <key>", "--active", "--json", "--no-color", "Example:"}},
142
+ {args: []string{"task", "auth", "--help"}, want: []string{"task-plugin options are passed through unchanged", "no flags", "Example:"}},
143
+ } {
144
+ text := scopedHelp(t, tc.args...)
145
+ for _, want := range tc.want {
146
+ if !strings.Contains(text, want) {
147
+ t.Fatalf("help %v missing %q:\n%s", tc.args, want, text)
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ func TestScopedHelpDoesNotRequireRelayFlowHomeOrServer(t *testing.T) {
154
+ home := filepath.Join(t.TempDir(), "missing-home")
155
+ t.Setenv("RELAY_FLOW_HOME", home)
156
+ code, output := captureStdout(t, func() int {
157
+ return run([]string{"repo", "register", "--help"}, strings.NewReader(""))
158
+ })
159
+ if code != exitOK {
160
+ t.Fatalf("help exit = %d, want %d", code, exitOK)
161
+ }
162
+ if !strings.Contains(output, "Usage: relay-flow repo register") {
163
+ t.Fatalf("help output = %q", output)
164
+ }
165
+ if _, err := os.Stat(home); !os.IsNotExist(err) {
166
+ t.Fatalf("help touched relay-flow home: stat error = %v", err)
167
+ }
168
+ }
169
+
170
+ func scopedHelp(t *testing.T, args ...string) string {
171
+ t.Helper()
172
+ var out bytes.Buffer
173
+ if code := printScopedHelp(args, &out); code != exitOK {
174
+ t.Fatalf("help %v exit = %d", args, code)
175
+ }
176
+ return out.String()
177
+ }
178
+
67
179
  func TestFilteredEmptyRunListShowsEffectiveFilters(t *testing.T) {
68
180
  var out bytes.Buffer
69
181
  active := true
@@ -157,7 +157,8 @@ func (a *Activities) LoadNodeRuntime(ctx context.Context, id run.ID, node string
157
157
 
158
158
  // EnsureNodeRuntime uses only persisted terminal/session IDs on the normal
159
159
  // path. A live terminal is rebound to the new visit; otherwise EnsureTerminal
160
- // creates a replacement and its direct ID is persisted immediately.
160
+ // creates a replacement and its direct ID is persisted immediately. A stored
161
+ // session receives feedback whether its terminal is reused or replaced.
161
162
  func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, repoPath string, spec harness.LaunchSpec, rt NodeRuntime) error {
162
163
  a.Runs.runtimeMu.Lock()
163
164
  defer a.Runs.runtimeMu.Unlock()
@@ -196,6 +197,10 @@ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, rep
196
197
  rt.TerminalID = currentRuntime.TerminalID
197
198
  rt.SessionID = currentRuntime.SessionID
198
199
  spec.ResumeID = rt.SessionID
200
+ promptKind := harness.PromptInitial
201
+ if rt.SessionID != "" {
202
+ promptKind = harness.PromptFeedback
203
+ }
199
204
  stored := runner.Terminal{ID: rt.TerminalID, Title: spec.Title}
200
205
  terminal, live, err := a.Runner.FindTerminal(ctx, stored)
201
206
  if err != nil {
@@ -210,7 +215,7 @@ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, rep
210
215
  // Same-visit retry/restart is silent: do not render, build, or send.
211
216
  return nil
212
217
  }
213
- prompt, err := a.Harness.RenderPrompt(harness.PromptFeedback, spec.PromptData, spec.NudgePrompt)
218
+ prompt, err := a.Harness.RenderPrompt(promptKind, spec.PromptData, spec.NudgePrompt)
214
219
  if err != nil {
215
220
  return err
216
221
  }
@@ -224,14 +229,14 @@ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, rep
224
229
  }
225
230
  }
226
231
 
227
- // An initial or replacement terminal resumes the stored session and
228
- // receives the rendered initial prompt. Same-visit replacements omit the
229
- // node nudge; a new visit includes it.
232
+ // A fresh session receives the initial prompt; a stored session receives
233
+ // feedback even when its terminal must be replaced. Same-visit
234
+ // replacements omit the node nudge; a new visit includes it.
230
235
  nudge := ""
231
236
  if rt.NodeVisitID == "" || revisit {
232
237
  nudge = spec.NudgePrompt
233
238
  }
234
- spec.Prompt, err = a.Harness.RenderPrompt(harness.PromptInitial, spec.PromptData, nudge)
239
+ spec.Prompt, err = a.Harness.RenderPrompt(promptKind, spec.PromptData, nudge)
235
240
  if err != nil {
236
241
  return err
237
242
  }
@@ -369,6 +369,10 @@ func TestEndAppliesConfigAndCompletes(t *testing.T) {
369
369
  if cleanupIdx < endApplyIdx {
370
370
  t.Fatalf("runner cleanup ran before end taskConfig; events=%v", events)
371
371
  }
372
+ closeIdx := indexOf(events, "closeTerminal:PAY-101:coding")
373
+ if closeIdx < 0 || cleanupIdx >= closeIdx {
374
+ t.Fatalf("runner cleanup did not precede runtime finalization: cleanup=%d close=%d events=%v", cleanupIdx, closeIdx, events)
375
+ }
372
376
  r2, _ := engine.GetRun(context.Background(), rid)
373
377
  if r2.State == run.StateCompleted && r2.FinishedAt == nil {
374
378
  t.Fatal("completed run has no FinishedAt")
@@ -382,6 +386,54 @@ func TestEndAppliesConfigAndCompletes(t *testing.T) {
382
386
  }
383
387
  }
384
388
 
389
+ func TestEndCleanupRetriesDirtyCheckoutBeforeFinalization(t *testing.T) {
390
+ log := newEventLog()
391
+ sys := newFakeTaskSystem(log)
392
+ fr := newFakeRunner(log)
393
+ fr.cleanupDirty = true
394
+ engine := newEngine(t, goworkflows.Dependencies{
395
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
396
+ Runtime: &run.RuntimePolicy{KeepTerminalsAlive: true, KeepSessionsAlive: true},
397
+ })
398
+ rid, _ := startRun(engine, linearWorkflow(true))
399
+ waitFor(t, 10*time.Second, func() bool {
400
+ r, _ := engine.GetRun(context.Background(), rid)
401
+ return r.CurrentNode == "coding"
402
+ })
403
+ if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end"))); err != nil {
404
+ t.Fatal(err)
405
+ }
406
+
407
+ waitFor(t, 10*time.Second, func() bool {
408
+ return indexOf(log.all(), "cleanupRunDirty:"+string(rid)) >= 0
409
+ })
410
+ r, _ := engine.GetRun(context.Background(), rid)
411
+ if r.State == run.StateCompleted || r.FinishedAt != nil {
412
+ t.Fatalf("run completed while dirty cleanup was retrying: %+v", r)
413
+ }
414
+ if fr.liveTerminals() != 1 {
415
+ t.Fatalf("dirty cleanup closed the terminal before retry: live=%d events=%v", fr.liveTerminals(), log.all())
416
+ }
417
+ if indexOf(log.all(), "closeTerminal:PAY-101:coding") >= 0 {
418
+ t.Fatalf("runtime finalization ran before dirty cleanup retry: events=%v", log.all())
419
+ }
420
+
421
+ waitFor(t, 10*time.Second, func() bool {
422
+ r, _ := engine.GetRun(context.Background(), rid)
423
+ return r.State == run.StateCompleted
424
+ })
425
+ events := log.all()
426
+ dirtyIdx := indexOf(events, "cleanupRunDirty:"+string(rid))
427
+ cleanupIdx := indexOf(events, "cleanupRun:"+string(rid))
428
+ closeIdx := indexOf(events, "closeTerminal:PAY-101:coding")
429
+ if dirtyIdx < 0 || cleanupIdx < 0 || closeIdx < 0 || dirtyIdx >= cleanupIdx || cleanupIdx >= closeIdx {
430
+ t.Fatalf("cleanup retry/finalization order = %v", events)
431
+ }
432
+ if len(fr.cleaned) != 1 {
433
+ t.Fatalf("successful CleanupRun calls = %v, want one after the dirty retry", fr.cleaned)
434
+ }
435
+ }
436
+
385
437
  func TestEndCleanupDisabledKeepsRetainedRunner(t *testing.T) {
386
438
  log := newEventLog()
387
439
  fr := newFakeRunner(log)
@@ -340,13 +340,14 @@ func (s *fakeTaskSystem) parentByKey(key string) (task.Ticket, bool) {
340
340
  type fakeRunner struct {
341
341
  log *eventLog
342
342
 
343
- mu sync.Mutex
344
- envs map[string]runner.Environment
345
- terminals map[string]*fakeTerminal // envID/title
346
- cleaned []string
347
- closedRun []string
348
- createErr error
349
- nextID int
343
+ mu sync.Mutex
344
+ envs map[string]runner.Environment
345
+ terminals map[string]*fakeTerminal // envID/title
346
+ cleaned []string
347
+ closedRun []string
348
+ cleanupDirty bool
349
+ createErr error
350
+ nextID int
350
351
  }
351
352
 
352
353
  type fakeTerminal struct {
@@ -460,6 +461,11 @@ func (f *fakeRunner) CloseTerminals(_ context.Context, spec runner.RunSpec) erro
460
461
  func (f *fakeRunner) CleanupRun(_ context.Context, spec runner.RunSpec) error {
461
462
  f.mu.Lock()
462
463
  defer f.mu.Unlock()
464
+ if f.cleanupDirty {
465
+ f.cleanupDirty = false
466
+ f.log.add("cleanupRunDirty:" + string(spec.RunID))
467
+ return errDirtyCleanup
468
+ }
463
469
  prefix := "env-" + spec.TicketKey + "/"
464
470
  for k := range f.terminals {
465
471
  if len(k) >= len(prefix) && k[:len(prefix)] == prefix {
@@ -581,3 +587,4 @@ func (e *conflictError) Error() string { return e.msg }
581
587
 
582
588
  var errConflict = &conflictError{msg: "human moved mailbox"}
583
589
  var errStartConflict = &conflictError{msg: "human moved ticket status"}
590
+ var errDirtyCleanup = &transientError{msg: "ticket checkout is dirty; commit required before runner cleanup"}
@@ -484,13 +484,10 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
484
484
  if wf.CleanupRunnerOnEnd {
485
485
  finalPolicy.KeepTerminalsAlive = false
486
486
  }
487
- if _, err := retryLoop(ctx, start.ID, a, work, "",
488
- func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
489
- return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries,
490
- a.FinalizeNodeRuntimes, work, start.RepoPath, finalPolicy)
491
- }); err != nil {
492
- return err
493
- }
487
+ // Cleanup must run before finalizing node runtimes when enabled. The
488
+ // runner performs its Git cleanliness check before closing terminals, so a
489
+ // dirty checkout leaves the agent terminal available for a commit while
490
+ // retryLoop waits.
494
491
  if wf.CleanupRunnerOnEnd {
495
492
  if _, err := retryLoop(ctx, start.ID, a, work, "",
496
493
  func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
@@ -499,6 +496,13 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
499
496
  return err
500
497
  }
501
498
  }
499
+ if _, err := retryLoop(ctx, start.ID, a, work, "",
500
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
501
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries,
502
+ a.FinalizeNodeRuntimes, work, start.RepoPath, finalPolicy)
503
+ }); err != nil {
504
+ return err
505
+ }
502
506
  endFinished := goworkflow.Now(ctx).UTC()
503
507
  endStep.Status, endStep.FinishedAt = run.StepSucceeded, &endFinished
504
508
  if _, err := retryLoop(ctx, start.ID, a, work, "end",
@@ -189,7 +189,7 @@ func TestNodeRuntimeSessionRegistrationKeepsOldSessionBoundToOldVisit(t *testing
189
189
  }
190
190
  }
191
191
 
192
- func TestEnsureNodeRuntimeUsesDirectIDsAndFallsBackFresh(t *testing.T) {
192
+ func TestEnsureNodeRuntimeUsesDirectIDsAndResumesWithFeedback(t *testing.T) {
193
193
  ctx := context.Background()
194
194
  fr := &runtimeTestRunner{}
195
195
  fh := &runtimeTestHarness{}
@@ -233,12 +233,12 @@ func TestEnsureNodeRuntimeUsesDirectIDsAndFallsBackFresh(t *testing.T) {
233
233
  if len(fh.resumeIDs) != 1 || fh.resumeIDs[0] != "dead-session" {
234
234
  t.Fatalf("BuildCommand ResumeIDs = %v, want [dead-session]", fh.resumeIDs)
235
235
  }
236
- if !reflect.DeepEqual(fh.rendered, []harness.PromptKind{harness.PromptInitial}) {
237
- t.Fatalf("dead-terminal rendered prompts = %v, want initial", fh.rendered)
236
+ if !reflect.DeepEqual(fh.rendered, []harness.PromptKind{harness.PromptFeedback}) {
237
+ t.Fatalf("dead-terminal rendered prompts = %v, want feedback", fh.rendered)
238
238
  }
239
239
  for _, prompt := range fh.prompts {
240
- if prompt != "work" {
241
- t.Fatalf("same-visit relaunch prompt = %q, want standard prompt only", prompt)
240
+ if prompt != "feedback" {
241
+ t.Fatalf("same-visit relaunch prompt = %q, want feedback only", prompt)
242
242
  }
243
243
  }
244
244
  }
@@ -363,7 +363,7 @@ func TestEnsureNodeRuntimeSendFailureClosesLiveTerminal(t *testing.T) {
363
363
  if len(fr.sentTexts) != 1 || fr.sentTexts[0] != "feedback\n\nRead the latest review feedback." {
364
364
  t.Fatalf("live revisit prompt = %q", fr.sentTexts)
365
365
  }
366
- if len(fh.prompts) != 1 || fh.prompts[0] != "work\n\nRead the latest review feedback." {
366
+ if len(fh.prompts) != 1 || fh.prompts[0] != "feedback\n\nRead the latest review feedback." {
367
367
  t.Fatalf("revisit replacement prompt = %q", fh.prompts)
368
368
  }
369
369
  rt, _ := p.getNodeRuntime(ctx, id, "implement")
@@ -373,8 +373,8 @@ func TestEnsureNodeRuntimeSendFailureClosesLiveTerminal(t *testing.T) {
373
373
  if len(fh.resumeIDs) != 1 || fh.resumeIDs[0] != "session-old" {
374
374
  t.Fatalf("replacement ResumeIDs = %v, want [session-old]", fh.resumeIDs)
375
375
  }
376
- if !reflect.DeepEqual(fh.rendered, []harness.PromptKind{harness.PromptFeedback, harness.PromptInitial}) {
377
- t.Fatalf("rendered prompts = %v, want feedback then replacement initial", fh.rendered)
376
+ if !reflect.DeepEqual(fh.rendered, []harness.PromptKind{harness.PromptFeedback, harness.PromptFeedback}) {
377
+ t.Fatalf("rendered prompts = %v, want feedback then replacement feedback", fh.rendered)
378
378
  }
379
379
  }
380
380