Skip to content

Uncaught 'stale extension ctx' in continueWhenIdle crashes host process after plan approval (embedded/multi-node hosts) #1069

Description

@wloonis

Summary

The continueWhenIdle callback registered in the agent_end handler (apps/pi-extension/index.ts, plannotator_submit_plan flow) throws an uncaught "stale extension ctx" error inside a setTimeout after a plan is approved, which crashes the host process when the planner session is torn down before the deferred callback runs.

This does not happen in plannotator's primary single-session flow (where plan + execution stay in the same pi session). It only happens in an embedded, multi-node host (Archon) that disposes of the planner session right after the plan is approved and the node completes. The crash orphans the whole workflow run.

Version

@plannotator/pi-extension 0.23.1, loaded from source (index.ts) via bun.

Reproduction

Host: Archon running a multi-node workflow where plannotator_submit_plan is used in a dedicated "planner" node and implementation is a separate node (a fresh pi session).

  1. Planner node calls plannotator_submit_plan → plan review browser opens on 127.0.0.1:19432.
  2. User approves the plan in the browser.
  3. plannotator_submit_plan returns the approved prompt with terminate: true; phase = "executing", justApprovedPlan = true.
  4. The planner prints its "plan approved" message and the turn ends → agent_end fires.
  5. In agent_end, because phase === "executing" && justApprovedPlan, the code schedules setTimeout(continueWhenIdle, 0), where continueWhenIdle polls the captured ctx.isIdle() and then calls pi.sendUserMessage("Continue with the approved plan.").
  6. The host disposes of the planner session (the node completed) — this invalidates the captured ctx's runner (runner.staleMessage is set by runner.invalidate(), called from session.dispose()).
  7. The deferred continueWhenIdle runs, calls ctx.isIdle()runner.assertActive() → throws because runner.staleMessage is set.
  8. The error is uncaught inside setTimeout → the host's Bun process crashes → the workflow run is orphaned (marked running in the DB, but no live process; it can never advance to the next node).

Stack trace (exact)

333 |             this.runtime.invalidate(message);
334 |         }
335 |     }
336 |     assertActive() {
337 |         if (this.staleMessage) {
338 |             throw new Error(this.staleMessage);
                                                   ^
error: This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().
      at assertActive (.../@earendil-works/pi-coding-agent/dist/core/extensions/runner.js:335:46)
      at isIdle (.../@earendil-works/pi-coding-agent/dist/core/extensions/runner.js:461:24)
      at continueWhenIdle (.../@plannotator/pi-extension/index.ts:1160:24)

Bun v1.3.14 (Linux x64)

Root cause

In apps/pi-extension/index.ts (around the agent_end handler):

pi.on("agent_end", async (_event, ctx) => {
    if (phase === "executing" && justApprovedPlan) {
        justApprovedPlan = false;
        let attempts = 0;
        const continueWhenIdle = (): void => {
            if (!ctx.isIdle()) {            // <-- ctx captured here, used after session disposal
                attempts += 1;
                if (attempts <= 200) setTimeout(continueWhenIdle, 50);
                return;
            }
            pi.sendUserMessage("Continue with the approved plan.");
        };
        setTimeout(continueWhenIdle, 0);   // <-- deferred; session may be disposed by then
        return;
    }
    ...

ctx is captured in the agent_end handler and then used in a deferred setTimeout callback. pi's extension-ctx contract explicitly says: "Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload()." An embedded host that disposes of the planner session after the node completes (entirely reasonable for a multi-node architecture) invalidates that captured ctx's runner, so the deferred ctx.isIdle() throws. Because the throw happens inside setTimeout, it is uncaught and takes down the host process.

This is fine in plannotator's interactive single-session flow (the session stays alive, so the captured ctx never goes stale), but it is not safe for embedded hosts that manage the session lifecycle per-node.

Proposed fix (plannotator-side, defensive)

Make continueWhenIdle resilient to a disposed/stale ctx so an embedded host is not taken down. Minimal change:

const continueWhenIdle = (): void => {
    try {
        if (!ctx.isIdle()) {
            attempts += 1;
            if (attempts <= 200) setTimeout(continueWhenIdle, 50);
            return;
        }
        pi.sendUserMessage("Continue with the approved plan.");
    } catch {
        // The planner session was replaced/disposed by the host after the plan
        // was approved and the node completed. There is nothing to continue —
        // stop the loop so the host can advance to its next node instead of
        // crashing the process with an uncaught "stale extension ctx" error
        // from a captured ctx.
    }
};

Alternative (stronger): instead of capturing ctx, re-acquire a live reference inside the callback, or guard with a "session still active" check before polling.

Workaround (tested locally)

We applied the try/catch above locally and confirmed the crash no longer occurs: the workflow advances cleanly from the planner node to the next node (verify-planimplement) after plan approval, with 0 stale-ctx errors in the run log. In our host (Archon) the "Continue with the approved plan" continuation is unwanted anyway — implementation is a separate node — so silently stopping the loop is exactly the desired behavior.

Environment

  • @plannotator/pi-extension 0.23.1 (loaded from index.ts)
  • pi-coding-agent 0.80.7 (bun hoisted cache copy)
  • Bun 1.3.14, Linux x64
  • Host: Archon (multi-node workflow, planner session disposed after node completes)

Happy to provide more detail or a minimal host repro if useful. Thanks!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions