fix(subagents): preserve restart and wait ordering
- commit
- 796aaac4d88c6a3baa92081cf2d7d0dfcc96bf50
- author
- brice · · 2026-08-09 09:17 UTC
- parent
- 7b4c47b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +66 -16
M README.md +1 -1
| @@ -254,7 +254,7 @@ When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits r | |||
| 254 | 254 | ||
| 255 | 255 | `--orchestrate` explicitly enables bounded delegation for `-p`/`--print`, `--stream`, or `--json`; ordinary headless runs never delegate implicitly. The parent needs `subagent_spawn`: it is available with the default tool set, or must be named in `--tools subagent_spawn` (and is disabled by `--no-tools`). A packaged agent's `PermissionSet` does not expose `subagent_spawn`, so packaged-agent and profile-worker modes cannot use orchestration. Interactive and RPC modes do not support this flag, and `--stats` cannot be combined with it. | |
| 256 | 256 | ||
| 257 | - | Workers inherit the parent CLI provider, model, and reasoning selection. A named profile from [docs/subagents.md](docs/subagents.md) may explicitly override its own provider/model/reasoning; profile metadata and the profile body are loaded only after the parent selects that profile. Print mode writes only the final synthesis; stream mode renders every parent turn to stdout while tool diagnostics remain on stderr; JSON mode retains every parent event as parseable JSONL, including completion updates as existing `user_message` events. Worker output is evidence for the next parent turn, not a host log. | |
| 257 | + | Workers inherit the parent CLI provider, model, and reasoning selection. Profile metadata from [docs/subagents.md](docs/subagents.md) is provided to the parent through `[subagents_list]` before selection; only the selected profile body and child-specific provider/model/reasoning configuration are loaded or applied afterward. Print mode writes only the final synthesis; stream mode renders every parent turn to stdout while tool diagnostics remain on stderr; JSON mode retains every parent event as parseable JSONL, including completion updates as existing `user_message` events. Worker output is evidence for the next parent turn, not a host log. | |
| 258 | 258 | ||
| 259 | 259 | The supervisor enforces configured concurrency, per-parent concurrency, queue/deadline, active-turn, output, and cancellation-grace limits; a headless invocation permits at most 32 completion follow-up waves. Cancellation stops the parent and workers through the normal graceful shutdown path. Workers cannot spawn descendants. See [docs/subagents.md](docs/subagents.md) for the profile manifest, limits, and unsupported combinations. | |
| 260 | 260 | ||
| @@ -254,7 +254,7 @@ When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits r | |||||
| 254 | 254 | ||||
| 255 | `--orchestrate` explicitly enables bounded delegation for `-p`/`--print`, `--stream`, or `--json`; ordinary headless runs never delegate implicitly. The parent needs `subagent_spawn`: it is available with the default tool set, or must be named in `--tools subagent_spawn` (and is disabled by `--no-tools`). A packaged agent's `PermissionSet` does not expose `subagent_spawn`, so packaged-agent and profile-worker modes cannot use orchestration. Interactive and RPC modes do not support this flag, and `--stats` cannot be combined with it. | 255 | `--orchestrate` explicitly enables bounded delegation for `-p`/`--print`, `--stream`, or `--json`; ordinary headless runs never delegate implicitly. The parent needs `subagent_spawn`: it is available with the default tool set, or must be named in `--tools subagent_spawn` (and is disabled by `--no-tools`). A packaged agent's `PermissionSet` does not expose `subagent_spawn`, so packaged-agent and profile-worker modes cannot use orchestration. Interactive and RPC modes do not support this flag, and `--stats` cannot be combined with it. | ||
| 256 | 256 | ||||
| 257 | - | Workers inherit the parent CLI provider, model, and reasoning selection. A named profile from [docs/subagents.md](docs/subagents.md) may explicitly override its own provider/model/reasoning; profile metadata and the profile body are loaded only after the parent selects that profile. Print mode writes only the final synthesis; stream mode renders every parent turn to stdout while tool diagnostics remain on stderr; JSON mode retains every parent event as parseable JSONL, including completion updates as existing `user_message` events. Worker output is evidence for the next parent turn, not a host log. | 257 | + | Workers inherit the parent CLI provider, model, and reasoning selection. Profile metadata from [docs/subagents.md](docs/subagents.md) is provided to the parent through `[subagents_list]` before selection; only the selected profile body and child-specific provider/model/reasoning configuration are loaded or applied afterward. Print mode writes only the final synthesis; stream mode renders every parent turn to stdout while tool diagnostics remain on stderr; JSON mode retains every parent event as parseable JSONL, including completion updates as existing `user_message` events. Worker output is evidence for the next parent turn, not a host log. |
| 258 | 258 | ||||
| 259 | The supervisor enforces configured concurrency, per-parent concurrency, queue/deadline, active-turn, output, and cancellation-grace limits; a headless invocation permits at most 32 completion follow-up waves. Cancellation stops the parent and workers through the normal graceful shutdown path. Workers cannot spawn descendants. See [docs/subagents.md](docs/subagents.md) for the profile manifest, limits, and unsupported combinations. | 259 | The supervisor enforces configured concurrency, per-parent concurrency, queue/deadline, active-turn, output, and cancellation-grace limits; a headless invocation permits at most 32 completion follow-up waves. Cancellation stops the parent and workers through the normal graceful shutdown path. Workers cannot spawn descendants. See [docs/subagents.md](docs/subagents.md) for the profile manifest, limits, and unsupported combinations. | ||
| 260 | 260 | ||||
M packages/agent/modes/subagents_slash.go +4 -1
| @@ -249,6 +249,10 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) { | |||
| 249 | 249 | return | |
| 250 | 250 | } | |
| 251 | 251 | waitWatcherDone := i.subagentsWaitWatcherDone | |
| 252 | + | // Publish the initial state before starting the watcher. A worker may | |
| 253 | + | // already be done, and its completion must be the final status rather | |
| 254 | + | // than being overwritten by a late "waiting" update. | |
| 255 | + | i.subagentsStatus("waiting for "+a.ID, "") | |
| 252 | 256 | go func() { | |
| 253 | 257 | defer func() { | |
| 254 | 258 | if waitWatcherDone != nil { | |
| @@ -265,7 +269,6 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) { | |||
| 265 | 269 | i.subagentsStatus("completed "+a.ID, "") | |
| 266 | 270 | } | |
| 267 | 271 | }() | |
| 268 | - | i.subagentsStatus("waiting for "+a.ID, "") | |
| 269 | 272 | case "resume-session", "resume", "reattach", "reopen": | |
| 270 | 273 | if rest == "" { | |
| 271 | 274 | count := i.subagentsDialog.OpenForResume( | |
| @@ -249,6 +249,10 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) { | |||||
| 249 | return | 249 | return | ||
| 250 | } | 250 | } | ||
| 251 | waitWatcherDone := i.subagentsWaitWatcherDone | 251 | waitWatcherDone := i.subagentsWaitWatcherDone | ||
| 252 | + | // Publish the initial state before starting the watcher. A worker may | |||
| 253 | + | // already be done, and its completion must be the final status rather | |||
| 254 | + | // than being overwritten by a late "waiting" update. | |||
| 255 | + | i.subagentsStatus("waiting for "+a.ID, "") | |||
| 252 | go func() { | 256 | go func() { | ||
| 253 | defer func() { | 257 | defer func() { | ||
| 254 | if waitWatcherDone != nil { | 258 | if waitWatcherDone != nil { | ||
| @@ -265,7 +269,6 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) { | |||||
| 265 | i.subagentsStatus("completed "+a.ID, "") | 269 | i.subagentsStatus("completed "+a.ID, "") | ||
| 266 | } | 270 | } | ||
| 267 | }() | 271 | }() | ||
| 268 | - | i.subagentsStatus("waiting for "+a.ID, "") | |||
| 269 | case "resume-session", "resume", "reattach", "reopen": | 272 | case "resume-session", "resume", "reattach", "reopen": | ||
| 270 | if rest == "" { | 273 | if rest == "" { | ||
| 271 | count := i.subagentsDialog.OpenForResume( | 274 | count := i.subagentsDialog.OpenForResume( | ||
M packages/agent/modes/subagents_slash_test.go +3 -0
| @@ -290,6 +290,9 @@ func TestRunSupervisorWaitReportsCompletionBeforeCancellation(t *testing.T) { | |||
| 290 | 290 | if err != nil { | |
| 291 | 291 | t.Fatal(err) | |
| 292 | 292 | } | |
| 293 | + | // Exercise the ordering-sensitive path deterministically: /wait can be | |
| 294 | + | // invoked after the worker has already completed. | |
| 295 | + | a.Wait() | |
| 293 | 296 | watcherDone := make(chan struct{}) | |
| 294 | 297 | iv.subagentsWaitWatcherDone = func() { close(watcherDone) } | |
| 295 | 298 | iv.runSubagents(context.Background(), []string{"wait", a.ID}) | |
| @@ -290,6 +290,9 @@ func TestRunSupervisorWaitReportsCompletionBeforeCancellation(t *testing.T) { | |||||
| 290 | if err != nil { | 290 | if err != nil { | ||
| 291 | t.Fatal(err) | 291 | t.Fatal(err) | ||
| 292 | } | 292 | } | ||
| 293 | + | // Exercise the ordering-sensitive path deterministically: /wait can be | |||
| 294 | + | // invoked after the worker has already completed. | |||
| 295 | + | a.Wait() | |||
| 293 | watcherDone := make(chan struct{}) | 296 | watcherDone := make(chan struct{}) | ||
| 294 | iv.subagentsWaitWatcherDone = func() { close(watcherDone) } | 297 | iv.subagentsWaitWatcherDone = func() { close(watcherDone) } | ||
| 295 | iv.runSubagents(context.Background(), []string{"wait", a.ID}) | 298 | iv.runSubagents(context.Background(), []string{"wait", a.ID}) | ||
M packages/agent/subagents/persist.go +14 -14
| @@ -1080,25 +1080,14 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||
| 1080 | 1080 | currentRunTurns := existing.CurrentRunTurnsValue() | |
| 1081 | 1081 | pendingResume := existing.resumePromptState() | |
| 1082 | 1082 | pendingResumePrompt, pendingResumePromptAt, pendingResumePromptID := pendingResume.Prompt, pendingResume.AcceptedAt, pendingResume.CommandID | |
| 1083 | - | hadPendingResumePrompt := pendingResumePrompt != "" | |
| 1084 | 1083 | pendingResumeQueue := existing.resumePromptQueueSnapshot() | |
| 1085 | 1084 | if pendingResumePrompt != "" && pendingResumePromptID == "" { | |
| 1086 | 1085 | pendingResumePromptID = uuid.NewString() | |
| 1087 | 1086 | } | |
| 1088 | - | if resumePrompt != "" { | |
| 1089 | - | currentRunTurns = 0 | |
| 1090 | - | if pendingResumePrompt == "" { | |
| 1091 | - | pendingResumePrompt = resumePrompt | |
| 1092 | - | pendingResumePromptAt = now | |
| 1093 | - | pendingResumePromptID = uuid.NewString() | |
| 1094 | - | } else { | |
| 1095 | - | pendingResumeQueue = append(pendingResumeQueue, queuedResumePrompt{Prompt: resumePrompt, AcceptedAt: now, CommandID: uuid.NewString()}) | |
| 1096 | - | } | |
| 1097 | - | } | |
| 1098 | - | if resuming && resumePrompt == "" && pendingResumePrompt == "" && len(pendingResumeQueue) > 0 { | |
| 1087 | + | if resuming && pendingResumePrompt == "" && len(pendingResumeQueue) > 0 { | |
| 1099 | 1088 | // A host can exit after accepting an active-worker follow-up but before | |
| 1100 | - | // dispatching it. Make the first durable queued prompt the resumed | |
| 1101 | - | // worker's initial turn rather than leaving the queue stranded. | |
| 1089 | + | // dispatching it. Promote the oldest durable prompt before accepting a | |
| 1090 | + | // new one so scheduler and completion-tracker order stay aligned. | |
| 1102 | 1091 | queued := pendingResumeQueue[0] | |
| 1103 | 1092 | pendingResumePrompt = queued.Prompt | |
| 1104 | 1093 | pendingResumePromptAt = queued.AcceptedAt | |
| @@ -1108,6 +1097,17 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||
| 1108 | 1097 | } | |
| 1109 | 1098 | pendingResumeQueue = pendingResumeQueue[1:] | |
| 1110 | 1099 | } | |
| 1100 | + | hadPendingResumePrompt := pendingResumePrompt != "" | |
| 1101 | + | if resumePrompt != "" { | |
| 1102 | + | currentRunTurns = 0 | |
| 1103 | + | if pendingResumePrompt == "" { | |
| 1104 | + | pendingResumePrompt = resumePrompt | |
| 1105 | + | pendingResumePromptAt = now | |
| 1106 | + | pendingResumePromptID = uuid.NewString() | |
| 1107 | + | } else { | |
| 1108 | + | pendingResumeQueue = append(pendingResumeQueue, queuedResumePrompt{Prompt: resumePrompt, AcceptedAt: now, CommandID: uuid.NewString()}) | |
| 1109 | + | } | |
| 1110 | + | } | |
| 1111 | 1111 | if resuming && pendingResumePrompt != "" { | |
| 1112 | 1112 | // A retained follow-up is an explicit new run, including a queued | |
| 1113 | 1113 | // follow-up recovered after a host restart. Preserve lifetime turns but | |
| @@ -1080,25 +1080,14 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||||
| 1080 | currentRunTurns := existing.CurrentRunTurnsValue() | 1080 | currentRunTurns := existing.CurrentRunTurnsValue() | ||
| 1081 | pendingResume := existing.resumePromptState() | 1081 | pendingResume := existing.resumePromptState() | ||
| 1082 | pendingResumePrompt, pendingResumePromptAt, pendingResumePromptID := pendingResume.Prompt, pendingResume.AcceptedAt, pendingResume.CommandID | 1082 | pendingResumePrompt, pendingResumePromptAt, pendingResumePromptID := pendingResume.Prompt, pendingResume.AcceptedAt, pendingResume.CommandID | ||
| 1083 | - | hadPendingResumePrompt := pendingResumePrompt != "" | |||
| 1084 | pendingResumeQueue := existing.resumePromptQueueSnapshot() | 1083 | pendingResumeQueue := existing.resumePromptQueueSnapshot() | ||
| 1085 | if pendingResumePrompt != "" && pendingResumePromptID == "" { | 1084 | if pendingResumePrompt != "" && pendingResumePromptID == "" { | ||
| 1086 | pendingResumePromptID = uuid.NewString() | 1085 | pendingResumePromptID = uuid.NewString() | ||
| 1087 | } | 1086 | } | ||
| 1088 | - | if resumePrompt != "" { | 1087 | + | if resuming && pendingResumePrompt == "" && len(pendingResumeQueue) > 0 { |
| 1089 | - | currentRunTurns = 0 | |||
| 1090 | - | if pendingResumePrompt == "" { | |||
| 1091 | - | pendingResumePrompt = resumePrompt | |||
| 1092 | - | pendingResumePromptAt = now | |||
| 1093 | - | pendingResumePromptID = uuid.NewString() | |||
| 1094 | - | } else { | |||
| 1095 | - | pendingResumeQueue = append(pendingResumeQueue, queuedResumePrompt{Prompt: resumePrompt, AcceptedAt: now, CommandID: uuid.NewString()}) | |||
| 1096 | - | } | |||
| 1097 | - | } | |||
| 1098 | - | if resuming && resumePrompt == "" && pendingResumePrompt == "" && len(pendingResumeQueue) > 0 { | |||
| 1099 | // A host can exit after accepting an active-worker follow-up but before | 1088 | // A host can exit after accepting an active-worker follow-up but before | ||
| 1100 | - | // dispatching it. Make the first durable queued prompt the resumed | 1089 | + | // dispatching it. Promote the oldest durable prompt before accepting a |
| 1101 | - | // worker's initial turn rather than leaving the queue stranded. | 1090 | + | // new one so scheduler and completion-tracker order stay aligned. |
| 1102 | queued := pendingResumeQueue[0] | 1091 | queued := pendingResumeQueue[0] | ||
| 1103 | pendingResumePrompt = queued.Prompt | 1092 | pendingResumePrompt = queued.Prompt | ||
| 1104 | pendingResumePromptAt = queued.AcceptedAt | 1093 | pendingResumePromptAt = queued.AcceptedAt | ||
| @@ -1108,6 +1097,17 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo | |||||
| 1108 | } | 1097 | } | ||
| 1109 | pendingResumeQueue = pendingResumeQueue[1:] | 1098 | pendingResumeQueue = pendingResumeQueue[1:] | ||
| 1110 | } | 1099 | } | ||
| 1100 | + | hadPendingResumePrompt := pendingResumePrompt != "" | |||
| 1101 | + | if resumePrompt != "" { | |||
| 1102 | + | currentRunTurns = 0 | |||
| 1103 | + | if pendingResumePrompt == "" { | |||
| 1104 | + | pendingResumePrompt = resumePrompt | |||
| 1105 | + | pendingResumePromptAt = now | |||
| 1106 | + | pendingResumePromptID = uuid.NewString() | |||
| 1107 | + | } else { | |||
| 1108 | + | pendingResumeQueue = append(pendingResumeQueue, queuedResumePrompt{Prompt: resumePrompt, AcceptedAt: now, CommandID: uuid.NewString()}) | |||
| 1109 | + | } | |||
| 1110 | + | } | |||
| 1111 | if resuming && pendingResumePrompt != "" { | 1111 | if resuming && pendingResumePrompt != "" { | ||
| 1112 | // A retained follow-up is an explicit new run, including a queued | 1112 | // A retained follow-up is an explicit new run, including a queued | ||
| 1113 | // follow-up recovered after a host restart. Preserve lifetime turns but | 1113 | // follow-up recovered after a host restart. Preserve lifetime turns but | ||
M packages/agent/subagents/resume_prompt_test.go +44 -0
| @@ -230,6 +230,50 @@ func TestResumeWithPromptRestartTracksExistingPendingPromptBeforeNewPrompt(t *te | |||
| 230 | 230 | if len(batch) != 2 || batch[0].Task != pendingPrompt || batch[1].Task != newPrompt { | |
| 231 | 231 | t.Fatalf("completion order = %+v, want pending prompt then new prompt", batch) | |
| 232 | 232 | } | |
| 233 | + | ||
| 234 | + | // Recreate the durable queue-only state: no in-flight prompt, with the | |
| 235 | + | // previously queued command still awaiting scheduler consumption. | |
| 236 | + | if err := f.Stop(resumed.ID); err != nil { | |
| 237 | + | t.Fatal(err) | |
| 238 | + | } | |
| 239 | + | resumed.Wait() | |
| 240 | + | resumed.clearResumePrompt() | |
| 241 | + | if err := f.persistAgent(resumed); err != nil { | |
| 242 | + | t.Fatal(err) | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | const newestPrompt = "finally synthesize both reviews" | |
| 246 | + | queueTracker := NewCompletionTracker() | |
| 247 | + | restarted, err := f.ResumeWithPromptBefore(context.Background(), resumed.ID, newestPrompt, func(agent *Agent, prompt string) func() { | |
| 248 | + | return queueTracker.TrackFutureTurn(agent, prompt, true) | |
| 249 | + | }) | |
| 250 | + | if err != nil { | |
| 251 | + | t.Fatal(err) | |
| 252 | + | } | |
| 253 | + | select { | |
| 254 | + | case got := <-started: | |
| 255 | + | if got != restarted { | |
| 256 | + | t.Fatalf("queue-only restart agent = %p, want %p", got, restarted) | |
| 257 | + | } | |
| 258 | + | case <-time.After(time.Second): | |
| 259 | + | t.Fatal("queue-only restarted runner did not start") | |
| 260 | + | } | |
| 261 | + | if got := restarted.resumePrompt(); got != newPrompt { | |
| 262 | + | t.Fatalf("promoted queue prompt = %q, want %q", got, newPrompt) | |
| 263 | + | } | |
| 264 | + | queue = restarted.resumePromptQueueSnapshot() | |
| 265 | + | if len(queue) != 1 || queue[0].Prompt != newestPrompt { | |
| 266 | + | t.Fatalf("queue-only restart queue = %+v, want newest prompt second", queue) | |
| 267 | + | } | |
| 268 | + | restarted.OnTurnEnd(1, "") | |
| 269 | + | restarted.OnTurnEnd(2, "") | |
| 270 | + | batch, err = queueTracker.WaitIdle(context.Background()) | |
| 271 | + | if err != nil { | |
| 272 | + | t.Fatal(err) | |
| 273 | + | } | |
| 274 | + | if len(batch) != 2 || batch[0].Task != newPrompt || batch[1].Task != newestPrompt { | |
| 275 | + | t.Fatalf("queue-only completion order = %+v, want promoted prompt then newest prompt", batch) | |
| 276 | + | } | |
| 233 | 277 | } | |
| 234 | 278 | ||
| 235 | 279 | func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) { | |
| @@ -230,6 +230,50 @@ func TestResumeWithPromptRestartTracksExistingPendingPromptBeforeNewPrompt(t *te | |||||
| 230 | if len(batch) != 2 || batch[0].Task != pendingPrompt || batch[1].Task != newPrompt { | 230 | if len(batch) != 2 || batch[0].Task != pendingPrompt || batch[1].Task != newPrompt { | ||
| 231 | t.Fatalf("completion order = %+v, want pending prompt then new prompt", batch) | 231 | t.Fatalf("completion order = %+v, want pending prompt then new prompt", batch) | ||
| 232 | } | 232 | } | ||
| 233 | + | ||||
| 234 | + | // Recreate the durable queue-only state: no in-flight prompt, with the | |||
| 235 | + | // previously queued command still awaiting scheduler consumption. | |||
| 236 | + | if err := f.Stop(resumed.ID); err != nil { | |||
| 237 | + | t.Fatal(err) | |||
| 238 | + | } | |||
| 239 | + | resumed.Wait() | |||
| 240 | + | resumed.clearResumePrompt() | |||
| 241 | + | if err := f.persistAgent(resumed); err != nil { | |||
| 242 | + | t.Fatal(err) | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | const newestPrompt = "finally synthesize both reviews" | |||
| 246 | + | queueTracker := NewCompletionTracker() | |||
| 247 | + | restarted, err := f.ResumeWithPromptBefore(context.Background(), resumed.ID, newestPrompt, func(agent *Agent, prompt string) func() { | |||
| 248 | + | return queueTracker.TrackFutureTurn(agent, prompt, true) | |||
| 249 | + | }) | |||
| 250 | + | if err != nil { | |||
| 251 | + | t.Fatal(err) | |||
| 252 | + | } | |||
| 253 | + | select { | |||
| 254 | + | case got := <-started: | |||
| 255 | + | if got != restarted { | |||
| 256 | + | t.Fatalf("queue-only restart agent = %p, want %p", got, restarted) | |||
| 257 | + | } | |||
| 258 | + | case <-time.After(time.Second): | |||
| 259 | + | t.Fatal("queue-only restarted runner did not start") | |||
| 260 | + | } | |||
| 261 | + | if got := restarted.resumePrompt(); got != newPrompt { | |||
| 262 | + | t.Fatalf("promoted queue prompt = %q, want %q", got, newPrompt) | |||
| 263 | + | } | |||
| 264 | + | queue = restarted.resumePromptQueueSnapshot() | |||
| 265 | + | if len(queue) != 1 || queue[0].Prompt != newestPrompt { | |||
| 266 | + | t.Fatalf("queue-only restart queue = %+v, want newest prompt second", queue) | |||
| 267 | + | } | |||
| 268 | + | restarted.OnTurnEnd(1, "") | |||
| 269 | + | restarted.OnTurnEnd(2, "") | |||
| 270 | + | batch, err = queueTracker.WaitIdle(context.Background()) | |||
| 271 | + | if err != nil { | |||
| 272 | + | t.Fatal(err) | |||
| 273 | + | } | |||
| 274 | + | if len(batch) != 2 || batch[0].Task != newPrompt || batch[1].Task != newestPrompt { | |||
| 275 | + | t.Fatalf("queue-only completion order = %+v, want promoted prompt then newest prompt", batch) | |||
| 276 | + | } | |||
| 233 | } | 277 | } | ||
| 234 | 278 | ||||
| 235 | func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) { | 279 | func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) { | ||