You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
@@ -683,7 +684,23 @@ defmodule Shuttle.Dispatcher do
683
684
684
685
defprender_headless_notice(_),do: ""
685
686
686
-
defprender_exit_contractdo
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
+
defprender_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
+
defprender_exit_contract(_kind)do
687
704
render_block(
688
705
"Exit Contract",
689
706
nil,
@@ -849,7 +866,7 @@ defmodule Shuttle.Dispatcher do
849
866
850
867
[
851
868
String.trim(header),
852
-
render_exit_contract(),
869
+
render_exit_contract("oneshot"),
853
870
render_block("From User",nil,String.trim(yap))
854
871
]
855
872
|>Enum.join("\n\n")
@@ -1080,6 +1097,19 @@ defmodule Shuttle.Dispatcher do
1080
1097
end
1081
1098
end
1082
1099
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).
0 commit comments