Skip to content

Commit 40ca74a

Browse files
cailmdaleyclaude
andcommitted
fix(pinned): treat pinned roles as interactive interfaces, not loops
Pinned roles (kind: pinned) were modeled as looping oneshots (Option D): a status:active pinned role with no live session was eligible every poll tick and re-dispatched — wake, survey, find nothing, exit, repeat. A pinned status hub (shapepipe) was observed redispatching ~10x in 90min, burning tokens. The inverse failure: a worker on a pinned role auto-exited (park + kill $PPID) when idle, going dark while the human wanted to keep talking — forcing a manual resume of the closed chat. Root cause: status:active conflates "a session is attached" with "should be dispatched/looping" — correct for oneshots, wrong for an interactive interface a human drives. Fix, two halves: - Poller: exclude pinned roles from the autonomous tick in filter_eligible/2 (NOT eligible?, so explicit force/plain dispatch still launches them). A pinned active+sessionless role now sits idle until the human resumes. - Dispatcher: kind-aware exit contract. A pinned worker is told to STAY ALIVE at a clean checkpoint and never kill $PPID; the session ends only when the human parks the role (active -> open). kind threaded through the prompt opts via fiber_kind/1. Regressions: poller_test (no auto-dispatch of active pinned; no re-dispatch after exit; force-dispatch still launches) and dispatcher_test (pinned exit contract inverted vs oneshot). Full suite 459/0. Finding: ai-futures/shuttle/findings/finding-pinned-roles-are-interfaces-not-loops Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016RVNkdUCoxqgGVVhUoApVp
1 parent 9acd342 commit 40ca74a

4 files changed

Lines changed: 122 additions & 32 deletions

File tree

lib/shuttle/dispatcher.ex

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@ defmodule Shuttle.Dispatcher do
9797
resume_intent ->
9898
create_tmux_session(fiber_id, agent, work_dir, runner, prompt_context, resume_intent,
9999
felt_store: felt_store,
100-
uid: uid
100+
uid: uid,
101+
kind: fiber_kind(fiber)
101102
)
102103
end
103104
end
@@ -658,7 +659,7 @@ defmodule Shuttle.Dispatcher do
658659
# fresh directive.
659660
[
660661
header,
661-
render_exit_contract(),
662+
render_exit_contract(Keyword.get(opts, :kind, "oneshot")),
662663
render_headless_notice(Keyword.get(opts, :headless, false)),
663664
render_user_message_block(fiber_id, felt_opts)
664665
]
@@ -683,7 +684,23 @@ defmodule Shuttle.Dispatcher do
683684

684685
defp render_headless_notice(_), do: ""
685686

686-
defp render_exit_contract do
687+
# Pinned roles invert the default exit contract. A pinned role is an
688+
# INTERACTIVE INTERFACE — a status hub, a debug intake — that a human drives;
689+
# the session IS the interface, so a worker that runs out of immediate work
690+
# must STAY ALIVE and wait, not `kill $PPID`. The poll loop never re-spawns a
691+
# pinned role (see Poller.filter_eligible/2), so a worker that exits leaves the
692+
# interface dark until the human manually resumes it — exactly the dead-chat
693+
# gap that made this fix necessary. The session ends when the human ends it
694+
# (parking the role: `active → open`), never autonomously.
695+
defp render_exit_contract("pinned") do
696+
render_block(
697+
"Exit Contract",
698+
nil,
699+
"This is a pinned interactive role — a standing interface a human drives, not a one-shot task. Keep the fiber current as you work (outcome, history, findings, commits at clean checkpoints), but when you run out of immediate work DO NOT `kill $PPID`: stay alive and wait for the next message. The session is the interface; it ends only when the human parks the role (`active → open`), not when you finish the task at hand. The poll loop will not re-spawn this role, so exiting goes dark on the human until they manually resume — don't. Reply normally and wait when there's nothing left to drive."
700+
)
701+
end
702+
703+
defp render_exit_contract(_kind) do
687704
render_block(
688705
"Exit Contract",
689706
nil,
@@ -849,7 +866,7 @@ defmodule Shuttle.Dispatcher do
849866

850867
[
851868
String.trim(header),
852-
render_exit_contract(),
869+
render_exit_contract("oneshot"),
853870
render_block("From User", nil, String.trim(yap))
854871
]
855872
|> Enum.join("\n\n")
@@ -1080,6 +1097,19 @@ defmodule Shuttle.Dispatcher do
10801097
end
10811098
end
10821099

1100+
# The shuttle block's `kind` (new-format) / `mode` (old-format), defaulting to
1101+
# "oneshot". Threaded into the prompt so the exit contract can diverge for
1102+
# pinned interactive roles (stay alive) vs oneshot/standing work (exit).
1103+
defp fiber_kind(fiber) do
1104+
case Map.get(fiber, "shuttle") do
1105+
shuttle when is_map(shuttle) ->
1106+
Map.get(shuttle, "kind", Map.get(shuttle, "mode", "oneshot"))
1107+
1108+
_ ->
1109+
"oneshot"
1110+
end
1111+
end
1112+
10831113
# Dispatch: fresh worker (new session) or resume previous.
10841114
# `resume_intent` is `:fresh | {:previous, session_id}` from check_resume_intent/3.
10851115
defp create_tmux_session(fiber_id, agent, work_dir, runner, prompt_context, resume_intent, opts) do

lib/shuttle/poller.ex

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1668,8 +1668,33 @@ defmodule Shuttle.Poller do
16681668
end
16691669
end
16701670

1671+
# The autonomous-tick eligibility filter. Beyond the shared `eligible?`
1672+
# predicate, the tick excludes pinned roles: a pinned role is an INTERACTIVE
1673+
# INTERFACE (a status hub, a debug intake), not autonomous work. The human
1674+
# starts it (drag-to-in-flight / New session / Resume — all force-dispatch),
1675+
# the worker stays attached as the interface, and the session ends when the
1676+
# human ends it. The poll loop must never re-spawn it: a pinned `active` role
1677+
# whose session has ended (human closed the chat, worker crashed) would
1678+
# otherwise re-dispatch every tick — surveying, finding nothing, exiting —
1679+
# burning tokens on an idle interface (the shapepipe redispatch loop). Pinned
1680+
# roles remain explicitly dispatchable: force-dispatch bypasses this entirely,
1681+
# and a plain `shuttle-ctl dispatch <id>` still routes through `eligible?`
1682+
# (which has no pinned gate), so only the *autonomous* path is severed.
1683+
# See [[ai-futures/shuttle/findings/finding-pinned-roles-are-interfaces-not-loops]].
16711684
defp filter_eligible(candidates, state) do
1672-
Enum.filter(candidates, fn fiber -> eligible?(fiber, state) end)
1685+
Enum.filter(candidates, fn fiber ->
1686+
eligible?(fiber, state) and not pinned_role?(fiber)
1687+
end)
1688+
end
1689+
1690+
defp pinned_role?(fiber) do
1691+
case Map.get(fiber, "shuttle") do
1692+
shuttle when is_map(shuttle) ->
1693+
Map.get(shuttle, "kind", Map.get(shuttle, "mode", "oneshot")) == "pinned"
1694+
1695+
_ ->
1696+
false
1697+
end
16731698
end
16741699

16751700
defp eligible?(fiber, state) do
@@ -1718,14 +1743,15 @@ defmodule Shuttle.Poller do
17181743
MapSet.member?(state.claimed, fiber_id) ->
17191744
false
17201745

1721-
# A pinned role dispatches exactly like a oneshot: an `active` pinned
1722-
# fiber is LOOPING — eligible → dispatch → worker exits active →
1723-
# re-dispatch next poll, until a worker sets `status: closed` or a human
1724-
# parks it (`active → open`, the `pause` verb). `open` is the parked rest
1725-
# state, already excluded by the `status != "active"` gate above. So
1726-
# pinned needs no bespoke branch here; it falls through to the oneshot
1727-
# dependency check below. (Option D: parked=open, looping=active — the
1728-
# never-auto-dispatch special case was deleted.)
1746+
# Pinned roles need no bespoke branch HERE: this predicate also serves
1747+
# the explicit-dispatch path (`shuttle-ctl dispatch`, plain POST
1748+
# /dispatch), where a pinned role IS eligible — it's a human asking for
1749+
# it. The autonomous tick is what must never loop a pinned interface;
1750+
# that exclusion lives in `filter_eligible/2` (the tick's only caller),
1751+
# not here. A pinned `active` role with no live session therefore sits
1752+
# idle until the human re-attaches, instead of re-dispatching every
1753+
# poll. (Supersedes Option D's "active pinned = looping" model, which
1754+
# burned tokens re-surveying idle interfaces.)
17291755

17301756
# Standing roles have additional preconditions; oneshots go to dep check.
17311757
# Support both new-format (kind:) and old-format (mode:) shuttle blocks.

test/shuttle/dispatcher_test.exs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,24 @@ defmodule Shuttle.DispatcherTest do
183183
refute prompt =~ "Exit before context is half-full"
184184
end
185185

186+
test "render_prompt for a pinned role inverts the exit contract to stay-alive" do
187+
# A pinned role is an interactive interface — the worker must NOT kill $PPID
188+
# when it runs out of immediate work; it stays attached and waits. The
189+
# default (oneshot) contract is the opposite, so the two must not collide.
190+
pinned = Dispatcher.render_prompt("tests/haiku", kind: "pinned")
191+
assert pinned =~ "Exit Contract"
192+
assert pinned =~ "pinned interactive role"
193+
assert pinned =~ "DO NOT `kill $PPID`"
194+
assert pinned =~ "stay alive and wait"
195+
# The autonomous kill-on-exit instruction must be absent for pinned.
196+
refute pinned =~ "your final action must be `kill $PPID`"
197+
198+
# Oneshot (the default) keeps the kill-on-exit contract.
199+
oneshot = Dispatcher.render_prompt("tests/haiku")
200+
assert oneshot =~ "your final action must be `kill $PPID`"
201+
refute oneshot =~ "pinned interactive role"
202+
end
203+
186204
test "render_prompt carries the headless notice only when headless: true" do
187205
# Headless (-p) workers run unattended — the prompt must tell them the
188206
# human-gate exception can't apply, or they may park at a checkpoint that

test/shuttle/poller_test.exs

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,32 +1008,45 @@ defmodule Shuttle.PollerTest do
10081008
end)
10091009
end
10101010

1011-
test "poller auto-dispatches a LOOPING (status:active) pinned role, like a oneshot" do
1012-
# Option D: a pinned role at status:active is LOOPING — eligible and
1013-
# dispatched on the poll, exactly like an active oneshot. (Old model: the
1014-
# poller hard-skipped every pinned role; the never-auto-dispatch branch is
1015-
# gone.) The parked rest state is status:open, covered by the sibling test.
1016-
fiber = make_fiber("tests/pinned-looping", %{"status" => "active"})
1017-
MockRunner.set_fiber("tests/pinned-looping", fiber)
1018-
MockRunner.set_shuttle("tests/pinned-looping", "kind: pinned\n", "active")
1011+
test "poller does NOT auto-dispatch a status:active pinned role (it's an interface, not a loop)" do
1012+
# A pinned role is an INTERACTIVE INTERFACE: the human starts it, the worker
1013+
# stays attached, the session ends when the human ends it. The autonomous
1014+
# poll loop must never re-spawn it — a status:active pinned role whose session
1015+
# ended (human closed the chat, worker crashed) would otherwise re-dispatch
1016+
# every tick, surveying-and-exiting, burning tokens (the shapepipe loop).
1017+
# The exclusion lives in filter_eligible/2 (the tick), NOT in eligible? — so
1018+
# force-dispatch still launches it (covered below).
1019+
fiber = make_fiber("tests/pinned-active", %{"status" => "active"})
1020+
MockRunner.set_fiber("tests/pinned-active", fiber)
1021+
MockRunner.set_shuttle("tests/pinned-active", "kind: pinned\nagent: claude-opus\n", "active")
10191022

10201023
{:ok, poller} =
10211024
start_poller!(
1022-
name: :test_poller_pinned_looping,
1025+
name: :test_poller_pinned_active,
10231026
runner: MockRunner,
10241027
poll_interval_ms: 60_000,
10251028
felt_stores: ["/tmp"]
10261029
)
10271030

10281031
send(poller, :run_poll_cycle)
1032+
# Flush the poll cycle through the GenServer mailbox.
1033+
_ = Poller.snapshot(poller)
1034+
1035+
refute Enum.any?(MockRunner.commands(), fn {cmd, args} ->
1036+
cmd == "tmux" and hd(args) == "new-session"
1037+
end),
1038+
"the autonomous tick must not spawn a worker for a pinned role"
1039+
1040+
# But an explicit force-dispatch (the strip's "start"/"Resume" gesture) DOES
1041+
# launch it — pinned roles are human-dispatch-only, not never-dispatch.
1042+
assert {:ok, _session} =
1043+
Poller.dispatch_fiber(poller, "tests/pinned-active", force: true, ad_hoc: true)
10291044

10301045
assert wait_until(fn ->
10311046
Enum.any?(MockRunner.commands(), fn {cmd, args} ->
10321047
cmd == "tmux" and hd(args) == "new-session"
10331048
end)
10341049
end)
1035-
1036-
assert Enum.any?(Poller.snapshot(poller).eligible, &(&1.fiber_id == "tests/pinned-looping"))
10371050
end
10381051

10391052
test "poller does NOT auto-dispatch a PARKED (status:open) pinned role" do
@@ -1063,11 +1076,12 @@ defmodule Shuttle.PollerTest do
10631076
refute Enum.any?(Poller.snapshot(poller).eligible, &(&1.fiber_id == "tests/pinned-parked"))
10641077
end
10651078

1066-
test "a LOOPING pinned worker exit leaves the role active and re-dispatches next poll" do
1067-
# Option D: a pinned role at status:active is looping. On worker-exit-while-
1068-
# active it must stay status:active (NOT marked awaiting like a standing role)
1069-
# so the next poll re-dispatches it — that's the loop. Only STANDING/cron
1070-
# roles mark awaiting; a pinned worker that's genuinely done self-closes.
1079+
test "a pinned worker exit leaves the role active but the next poll does NOT re-dispatch" do
1080+
# A pinned worker exit must leave the document status:active (NOT marked
1081+
# awaiting/closed like a standing role) — the interface stays "open" for the
1082+
# human to resume. But the autonomous poll must NOT re-spawn it: that
1083+
# combination (active + sessionless + no re-dispatch) is the whole fix.
1084+
# Reverting filter_eligible's pinned guard re-arms the shapepipe token loop.
10711085
#
10721086
# The exit handler routes through felt (LifecycleStore → FeltStores.resolve_
10731087
# fiber), so the mock fiber must be felt-resolvable: point LOOM_HOMES at the
@@ -1115,10 +1129,12 @@ defmodule Shuttle.PollerTest do
11151129
refute doc =~ ~r/status:\s*closed/
11161130
refute doc =~ "closed-at"
11171131

1118-
# The loop: the next poll re-dispatches the still-active, no-longer-running
1119-
# role (a SECOND new-session for the same session name).
1132+
# No loop: the next poll does NOT re-dispatch the still-active, no-longer-
1133+
# running pinned role. The session count stays at 1 — the human must resume
1134+
# it explicitly. (Reverting the pinned guard flips this to a second launch.)
11201135
send(poller, :run_poll_cycle)
1121-
assert wait_until(fn -> new_sessions.() == 2 end)
1136+
_ = Poller.snapshot(poller)
1137+
assert new_sessions.() == 1
11221138
end
11231139

11241140
test "a standing worker exit DOES close the role to awaiting-review (status:closed)" do

0 commit comments

Comments
 (0)