backnotprop / backnotprop/plannotator

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

Open Beginner friendly
#1,069 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
8.7k
Forks
649
Avg merge
11h 12m
Merged PRs (30d)
109

Description

## 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):

```ts
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:

```ts
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-plan` → `implement`) 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!

Contributor guide

Open the contributing guide

Research direction

Start in apps/pi-extension/index.ts at the agent_end handler and the continueWhenIdle callback in the plannotator_submit_plan flow. Reproduce the embedded multi-node lifecycle where the planner session is disposed after approval, then verify that a stale ctx cannot crash the host and that the workflow advances to the next node.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
devtools
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.