Skip to content

Commit 8634ddf

Browse files
committed
Preserve resume dispatch failures
1 parent 55e96b0 commit 8634ddf

5 files changed

Lines changed: 354 additions & 17 deletions

File tree

AGENTS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,17 @@ shuttle/
187187
mix test # full Elixir suite (110 tests, ~7s)
188188
mix test --only focus # tagged subset
189189
go test ./pkg/schema/... # Go schema tests
190+
191+
# Opt-in real harness smoke. Opens real Claude/Codex/Pi CLIs in tmux,
192+
# sends no prompt, captures the idle pane, then kills the smoke sessions.
193+
SHUTTLE_REAL_HARNESS_SMOKE=1 mix test --only integration test/shuttle/real_harness_smoke_test.exs
190194
```
191195

196+
The real harness smoke is deliberately outside ordinary `mix test`. It uses
197+
tmux session names like `shuttle-harness-smoke-<harness>-<unique>`, records
198+
captures under `_build/test/shuttle_harness_smoke/`, and skips harnesses that
199+
are not available in `bash -l`.
200+
192201
## Contributing
193202

194203
See `CONTRIBUTING.md`.

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,10 @@ make clean # rm _build and stray .beam files
192192
193193
mix test # Elixir suite (110 tests)
194194
go test ./pkg/schema/... # Go schema tests
195+
196+
# Opt-in real harness smoke: opens Claude/Codex/Pi in tmux, sends no prompt,
197+
# captures the idle pane, then kills the smoke-owned sessions.
198+
SHUTTLE_REAL_HARNESS_SMOKE=1 mix test --only integration test/shuttle/real_harness_smoke_test.exs
195199
```
196200

197201
## License

lib/shuttle/poller.ex

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -421,13 +421,8 @@ defmodule Shuttle.Poller do
421421
{:reply, {:ok, "human"}, state}
422422

423423
dispatch_eligible?(fiber, state, opts) ->
424-
new_state = do_dispatch_fiber(state, fiber, opts)
425-
426-
if Map.has_key?(new_state.running, fiber_id) do
427-
{:reply, {:ok, session}, new_state}
428-
else
429-
{:reply, {:error, :dispatch_failed}, new_state}
430-
end
424+
{new_state, result} = do_dispatch_fiber(state, fiber, opts)
425+
{:reply, result || {:ok, session}, new_state}
431426

432427
true ->
433428
{:reply, {:error, :not_eligible}, state}
@@ -628,7 +623,8 @@ defmodule Shuttle.Poller do
628623

629624
Enum.reduce(dispatchable, state, fn fiber, state_acc ->
630625
if available_slots(state_acc) > 0 do
631-
do_dispatch_fiber(state_acc, fiber)
626+
{new_state, _result} = do_dispatch_fiber(state_acc, fiber)
627+
new_state
632628
else
633629
state_acc
634630
end
@@ -1284,7 +1280,7 @@ defmodule Shuttle.Poller do
12841280
# the kanban shows the card in inFlight (status:active, enabled:true)
12851281
# without any tmux session to watch.
12861282
Logger.info("Human-worker fiber #{fiber_id} accepted; no watcher started")
1287-
state
1283+
{state, {:ok, "human"}}
12881284

12891285
{:ok, session} ->
12901286
agent_name = fetch_shuttle_agent_name(fiber_id, state)
@@ -1325,23 +1321,26 @@ defmodule Shuttle.Poller do
13251321
}
13261322

13271323
broadcast_snapshot(state)
1328-
state
1324+
{state, {:ok, session}}
13291325

13301326
{:error, reason} ->
13311327
Logger.error("Failed to start watcher for #{fiber_id}: #{inspect(reason)}")
13321328

1333-
schedule_retry(state, fiber_id, 1, %{
1334-
error: "watcher start failed: #{inspect(reason)}"
1335-
})
1329+
state =
1330+
schedule_retry(state, fiber_id, 1, %{
1331+
error: "watcher start failed: #{inspect(reason)}"
1332+
})
1333+
1334+
{state, {:error, :watcher_start_failed}}
13361335
end
13371336

13381337
{:error, :already_running} ->
13391338
# Session exists but we don't have a watcher — adopt it
1340-
adopt_session(state, fiber_id)
1339+
{adopt_session(state, fiber_id), {:error, :already_running}}
13411340

13421341
{:error, reason} ->
13431342
Logger.warning("Dispatch failed for #{fiber_id}: #{inspect(reason)}")
1344-
state
1343+
{state, {:error, reason}}
13451344
end
13461345
end
13471346

@@ -1538,7 +1537,8 @@ defmodule Shuttle.Poller do
15381537
{:ok, fiber} ->
15391538
state =
15401539
if eligible?(fiber, state) do
1541-
do_dispatch_fiber(state, fiber)
1540+
{new_state, _result} = do_dispatch_fiber(state, fiber)
1541+
new_state
15421542
else
15431543
Logger.debug("Retry no longer eligible: #{fiber_id}")
15441544
release_claim(state, fiber_id)

test/shuttle/dispatch_integration_test.exs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
defmodule Shuttle.DispatchIntegrationTest do
22
use ExUnit.Case, async: false
33

4-
alias Shuttle.Dispatcher
4+
alias Shuttle.{Dispatcher, Poller}
55

66
# ── Integration Runner ─────────────────────────────────────────────────────
77
# Passes `felt` commands to the real felt CLI (with -C felt_store).
@@ -459,6 +459,42 @@ defmodule Shuttle.DispatchIntegrationTest do
459459
)
460460
end
461461

462+
test "poller dispatch API preserves missing session UUID errors", %{host: host} do
463+
write_fiber(host, "tests/poller-resume-no-uuid", """
464+
---
465+
name: Poller resume no UUID
466+
status: active
467+
tags:
468+
- constitution
469+
shuttle:
470+
enabled: true
471+
kind: oneshot
472+
agent: claude-sonnet
473+
---
474+
No session UUID in shuttle block.
475+
""")
476+
477+
append_review_comment(host, "tests/poller-resume-no-uuid",
478+
summary: "Please resume",
479+
resume_mode: "previous"
480+
)
481+
482+
{:ok, poller} =
483+
Poller.start_link(
484+
name: :test_poller_resume_no_uuid,
485+
runner: IntegrationRunner,
486+
poll_interval_ms: 600_000,
487+
felt_stores: [host]
488+
)
489+
490+
assert {:error, :missing_session_id} =
491+
Poller.dispatch_fiber(poller, "tests/poller-resume-no-uuid", [])
492+
493+
refute Enum.any?(IntegrationRunner.commands(), fn {cmd, args} ->
494+
cmd == "tmux" and Enum.at(args, 0) == "new-session"
495+
end)
496+
end
497+
462498
# review-comment with resume_mode=fresh explicitly requests a new session
463499
# even when a prior session UUID is stored.
464500
test "resume_mode=fresh takes fresh path even with stored session.id", %{host: host} do

0 commit comments

Comments
 (0)