Poller/Stager Pattern — Standalone Interview Fork

Status: ACTIVE (concept) Agent: opencode/ext-agent (sandshrew) Timestamp UTC: 2026-05-12T00:30:00Z Session: MjF design — categorical nesting + polling interview + stager fork, separate from active unit instance

Prior Context

The Separation

Two actors, one node:

UNIT INSTANCE                    POLLER/STAGER (forked)
────────────────────             ────────────────────────
Owns the node's work              Spawned on demand
Calls the agent harness           Runs interview loop
Produces output                   Polls player with MC
Reads staged config from state    Refines iteratively
Writes output to state            Stages config to state
Never talks to the player         Only talks to the player

The unit does the work. The poller helps the player configure what the unit should do. They communicate through state — the poller writes, the unit reads. The poller is a fork, not a sub-routine. It spawns, runs, resolves, and the unit picks up the results.

Top-Line Categories (The Entry Points)

When a node produces output and the player reviews it, these are the top-line options:

Category What It Triggers Poller Behavior
Retry Re-run the node with corrections Interview: what was wrong? → MC drill-down → stage corrections
Deepen Add depth to a specific section Interview: which section? → what's missing? → MC options
Redirect Change the unit's approach Interview: what direction instead? → MC presets
Config Change model/harness/tools Direct selection (no poller needed — just pick)
Route Send output to another node Existing routing submenu (no poller needed)
Accept Output is good, mark complete No poller. Done.

"Retry" and "Deepen" and "Redirect" trigger the poller. "Config" and "Route" and "Accept" are direct actions.

The Poller Interview Loop

The poller is a LangGraph subgraph or forked node with its own interrupt cycle:

┌─────────────────────────────────────────────────────────┐
                   POLLER INTERVIEW LOOP                   
                                                          
  START  present_category_question                       
                                                         
                                                         
         interrupt()  "What's wrong?"                    
                                                         
                                                         
         narrow_question                                  
                                                         
                                                         
         interrupt()  "Which section?"                   
                                                         
                                                         
         suggest_fix                                      
                                                         
                                                         
         interrupt()  "Would this fix it?"               
                                                         
         ┌────┼────┐                                      
                                                       
      Close  Correct  Not at all                          
                                                       
                                                       
      branch  stage  restart                              
      deeper  config  from top                            
                                                          
                                                          
    "Is it A? B? C? Combination?"                         
                                                          
                                                          
    interrupt()  player selects                          
                                                          
                                                          
    refine  loop back to "Would this fix it?"            
                                                          
          (Correct)                                       
    stage_config  write to state  END                   
└─────────────────────────────────────────────────────────┘

The Interpretation Echo

After every player selection, the poller says back what it thinks:

┌──────────────────────────────────────────────────────────┐
  POLLER: "You said: output too shallow. Missing:         │
│  performance benchmarks for the sprite sheet.             │
│  Suggested fix: add a 'Performance' section with          │
│  frame-time estimates for 36 hex renders."               
│──────────────────────────────────────────────────────────│
  Is this what you meant?                                 
  [A] Correct    [X] Close    [Y] Not at all    [B] Back  
└──────────────────────────────────────────────────────────┘

Correct → poller writes the staged config and resolves.
Close → poller branches deeper: "Is it A. wrong metric, B. wrong section, C. too specific, D. combination?" Player selects. Poller refines. Loops back.
Not at all → poller restarts from the top category question.

The Close → Branch Deeper Tree

When the player says "Close but not exactly":

┌──────────────────────────────────────────────────────────┐
  "Close but not exactly..."                              
│──────────────────────────────────────────────────────────│
  What's off?                                             
   A. The suggested section is wrong                     
   B. The metric is wrong (should be different data)     
   C. Too specific  need higher-level guidance          
   D. Combination of above                               
│──────────────────────────────────────────────────────────│
  [A-D] Select    [B] Back                                
└──────────────────────────────────────────────────────────┘

If D (combination), the poller presents a multi-select:

┌──────────────────────────────────────────────────────────┐
  Select all that apply:                                  
   A. Wrong section                                      
   B. Wrong metric                                       
   C. Too specific                                       
│──────────────────────────────────────────────────────────│
  [A] Toggle   [X] Confirm   [B] Back                     
└──────────────────────────────────────────────────────────┘

The poller then refines based on the combination and echoes back again. The loop continues until the player says "Correct" or aborts.

State: How Poller and Unit Communicate

The poller and unit share state keys. The poller writes. The unit reads.

# After poller resolves (player says "Correct"):
state["staged_configs"]["node_14"] = {
    "action": "retry",                    # what the unit should do
    "corrections": [                      # accumulated corrections
        "Add performance benchmarks section",
        "Focus on 36-hex render time specifically"
    ],
    "context_additions": [                # new context to include
        "wiki/research/sprite-formats.md#performance"
    ],
    "resolved_at": "2026-05-12T00:30:00Z",
    "poller_id": "poller-14-001"          # traceability
}

The unit, on its next invocation, reads staged_configs[node_id] and applies it:

def hex_14(state):
    staged = state.get("staged_configs", {}).get("node_14")
    if staged and staged["action"] == "retry":
        # Build system prompt with staged corrections
        system_prompt = f"""
        Previous output was corrected by player.
        Player said: {staged['corrections']}
        Additional context: {staged['context_additions']}
        Restructure accordingly.
        """
    # ... run agent with corrected prompt

The Fork Mechanism

The poller is a separate LangGraph node or subgraph. It's triggered when the player selects "Retry" or "Deepen" or "Redirect" from the interrupt menu.

# In the unit's interrupt handler:
action = interrupt({
    "options": ["accept", "retry", "deepen", "redirect", "config", "route"]
})

if action == "retry":
    # Fork to poller — this runs the interview loop
    return Command(goto="poller_node", update={"poller_context": {
        "trigger": "retry",
        "source_node": "node_14",
        "unit": "rif"
    }})

# The poller runs its interview, stages config, and returns:
# Command(goto="node_14", graph=parent) — back to the unit

The poller has its own interrupt() loops. The player interacts with the poller, not the unit. The poller resolves, writes to state, and routes back to the unit. The unit reads the staged config and continues.

Why This Separation Matters

Without Poller (unit does everything) With Poller (forked)
Unit's node function handles both work AND interview logic Unit only handles work
Interrupt options mixed with agent logic Poller's interrupts are clean interview steps
Hard to retry mid-interview Poller loops independently until resolved
Player correction directly modifies unit state Poller stages corrections; unit applies them atomically
One node function = one responsibility violation Two functions = clear separation of concerns

The poller is a "configuration session." The unit is a "work session." They're different activities with different interaction patterns. Separating them keeps both clean.

The "Graceful Resolve"

The poller always resolves. It never hangs. Exit paths:

The unit never waits for the poller indefinitely. The poller is a fire-and-resolve pattern.