backnotprop / backnotprop/plannotator
Uncaught 'stale extension ctx' in continueWhenIdle crashes host process after plan approval (embedded/multi-node hosts)
- 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
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