taskplane 0.30.3 → 0.30.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Taskplane
2
2
 
3
- Multi-agent AI orchestration for coding with [pi](https://github.com/badlogic/pi-mono) — parallel task execution, mono- and poly-repo support, fresh-context worker loops, cross-model reviews, automated merges and a killer dashboard!
3
+ Multi-agent AI orchestration for coding with [pi](https://github.com/earendil-works/pi) — parallel task execution, mono- and poly-repo support, fresh-context worker loops, cross-model reviews, automated merges and a killer dashboard!
4
4
 
5
5
  > **Status:** Initial release.
6
6
 
@@ -50,7 +50,7 @@ Taskplane is a pi package. You need Node.js 22+, pi and Git installed first.
50
50
  | Dependency | Required | Notes |
51
51
  |-----------|----------|-------|
52
52
  | [Node.js](https://nodejs.org/) ≥ 22 | Yes | Runtime |
53
- | [pi](https://github.com/badlogic/pi-mono) | Yes | Agent framework |
53
+ | [pi](https://github.com/earendil-works/pi) | Yes | Agent framework |
54
54
  | [Git](https://git-scm.com/) | Yes | Version control, worktrees |
55
55
 
56
56
  IMPORTANT: If you just installed pi, make sure you've configured at least one model provider and tested before installing Taskplane.
@@ -2020,7 +2020,16 @@ export function presentBatchSummary(
2020
2020
  (batchState.failedTasks > 0 ? `- **Failed:** ${batchState.failedTasks} task(s)\n` : "") +
2021
2021
  `\nFull summary written to \`.pi/supervisor/${filename}\`.`;
2022
2022
 
2023
- pi.sendMessage(
2023
+ // #597 (post-Sage-review): `presentBatchSummary` is a terminal best-effort
2024
+ // operation, and it can be reached from a timer-origin call chain via
2025
+ // `startHeartbeat → deactivateSupervisor → (state.pendingSummaryDeps)`.
2026
+ // If the captured `pi` handle has gone stale by the time the heartbeat
2027
+ // fires, an unwrapped `pi.sendMessage` here re-introduces the exact
2028
+ // uncaughtException pattern #597 is meant to prevent. Use the same
2029
+ // never-throw wrapper as the timer call sites: deliver the message if
2030
+ // pi is healthy, drop it silently if pi is stale.
2031
+ safeSendMessageFromTimer(
2032
+ pi,
2024
2033
  {
2025
2034
  customType: "supervisor-batch-summary",
2026
2035
  content: [{ type: "text", text: conciseText }],
@@ -3037,6 +3046,23 @@ export async function activateSupervisor(
3037
3046
  };
3038
3047
  writeLockfile(stateRoot, lock);
3039
3048
 
3049
+ // #597: Defensive teardown before installing new timers.
3050
+ //
3051
+ // `state` is a mutable singleton that persists across activate/deactivate
3052
+ // cycles. In re-activation paths (a previous activation that didn't go
3053
+ // through `deactivateSupervisor` cleanly, session churn after takeover,
3054
+ // etc.) `state.heartbeatTimer` and `state.eventTailer` may still reference
3055
+ // running timers that captured a now-stale `pi` handle. If we just
3056
+ // reassign `state.heartbeatTimer = startHeartbeat(...)` the previous
3057
+ // timer is orphaned — still ticking, still holding the stale `pi`, and
3058
+ // at the next tick its `pi.sendMessage()` call throws `assertActive` and
3059
+ // crashes the host process. Tear down explicitly before replacing.
3060
+ stopEventTailer(state.eventTailer);
3061
+ if (state.heartbeatTimer) {
3062
+ clearInterval(state.heartbeatTimer);
3063
+ state.heartbeatTimer = null;
3064
+ }
3065
+
3040
3066
  // Start heartbeat timer — updates lockfile every 30s, detects takeover
3041
3067
  state.heartbeatTimer = startHeartbeat(stateRoot, state, pi);
3042
3068
 
@@ -3710,6 +3736,87 @@ export function buildTakeoverSummary(stateRoot: string, batchState: PersistedBat
3710
3736
  *
3711
3737
  * @since TP-041
3712
3738
  */
3739
+
3740
+ // ── Stale extension-context guard (#597) ──────────────────────────────
3741
+ //
3742
+ // Pi throws "This extension ctx is stale after session replacement or reload."
3743
+ // from `assertActive` when an extension uses a captured `pi` handle after
3744
+ // `ctx.newSession()`, `ctx.fork()`, `ctx.switchSession()`, or `ctx.reload()`.
3745
+ // Background timers in this file (`startHeartbeat`, `startEventTailer`)
3746
+ // capture `pi` in a closure and call `pi.sendMessage()` at arbitrary times;
3747
+ // when the captured handle goes stale between timer ticks, an uncaught
3748
+ // throw from `pi.sendMessage()` becomes a process-fatal `uncaughtException`
3749
+ // that kills the entire Pi process — issue #597.
3750
+ //
3751
+ // `isStaleExtensionCtx` and `safeSendMessageFromTimer` together harden the
3752
+ // timer call sites: stale-ctx errors are recognized and swallowed (the timer
3753
+ // caller then stops itself), other errors are logged to stderr but do not
3754
+ // propagate. The supervisor surrenders its UI surface gracefully instead of
3755
+ // taking Pi down.
3756
+
3757
+ /**
3758
+ * Returns true when `err` is Pi's distinctive stale-extension-ctx error.
3759
+ *
3760
+ * Matched by error-message substring rather than by class identity because
3761
+ * the error class is not exported by `@earendil-works/pi-coding-agent` and
3762
+ * the message text is the stable, documented contract
3763
+ * (`core/extensions/loader.js:assertActive`).
3764
+ *
3765
+ * Defensive against non-Error throws (string throws, null, undefined, etc.)
3766
+ * which are not stale-ctx errors and should propagate to the caller's
3767
+ * normal error path — we only swallow the specific Pi case.
3768
+ *
3769
+ * @since #597
3770
+ */
3771
+ export function isStaleExtensionCtx(err: unknown): boolean {
3772
+ if (err === null || err === undefined) return false;
3773
+ // Read message off either an Error instance or a plain object with .message
3774
+ const message =
3775
+ typeof err === "object" && err !== null && "message" in err
3776
+ ? String((err as { message: unknown }).message ?? "")
3777
+ : typeof err === "string"
3778
+ ? err
3779
+ : "";
3780
+ return message.includes("This extension ctx is stale");
3781
+ }
3782
+
3783
+ /**
3784
+ * `pi.sendMessage()` wrapper for timer-context callers (heartbeat / event
3785
+ * tailer / digest timer).
3786
+ *
3787
+ * Returns `true` on success, `false` when the call was swallowed because
3788
+ * the extension context has gone stale (per `isStaleExtensionCtx`). Other
3789
+ * exceptions are logged via `console.error` but also do not propagate —
3790
+ * the timer caller stays alive and continues, since the safe-default for a
3791
+ * background timer in a long-running process is to keep ticking rather
3792
+ * than crash the host. Callers should treat a `false` return as "Pi has
3793
+ * replaced us; stop trying" and clear their own interval.
3794
+ *
3795
+ * @since #597
3796
+ */
3797
+ export function safeSendMessageFromTimer(
3798
+ pi: ExtensionAPI,
3799
+ message: Parameters<ExtensionAPI["sendMessage"]>[0],
3800
+ options?: Parameters<ExtensionAPI["sendMessage"]>[1],
3801
+ ): boolean {
3802
+ try {
3803
+ pi.sendMessage(message, options);
3804
+ return true;
3805
+ } catch (err) {
3806
+ if (isStaleExtensionCtx(err)) {
3807
+ // Pi has replaced us. The timer caller will see `false` and stop.
3808
+ return false;
3809
+ }
3810
+ // Unexpected error — log for diagnosis but do not crash the host.
3811
+ console.error(
3812
+ `[supervisor] pi.sendMessage from timer callback threw: ${
3813
+ err instanceof Error ? err.message : String(err)
3814
+ }`,
3815
+ );
3816
+ return true; // not stale; let the caller continue ticking
3817
+ }
3818
+ }
3819
+
3713
3820
  export function startHeartbeat(
3714
3821
  stateRoot: string,
3715
3822
  state: SupervisorState,
@@ -3731,9 +3838,14 @@ export function startHeartbeat(
3731
3838
  // Read current lockfile to detect force takeover — async (TP-070)
3732
3839
  const currentLock = await readLockfileAsync(stateRoot);
3733
3840
  if (currentLock && currentLock.sessionId !== sessionId) {
3734
- // Another session has taken over — yield gracefully
3841
+ // Another session has taken over — yield gracefully.
3842
+ // #597: the captured `pi` handle may be stale at this point;
3843
+ // use safeSendMessageFromTimer so a stale-ctx throw cannot
3844
+ // escape and become an uncaughtException that kills the
3845
+ // whole Pi process.
3735
3846
  clearInterval(timer);
3736
- pi.sendMessage(
3847
+ safeSendMessageFromTimer(
3848
+ pi,
3737
3849
  {
3738
3850
  customType: "supervisor-yield",
3739
3851
  content: [
@@ -4444,7 +4556,12 @@ export function startEventTailer(
4444
4556
  setStatus("supervisor", `🔀 ${statusText}`);
4445
4557
  }
4446
4558
 
4447
- pi.sendMessage(
4559
+ // #597: guard against stale-ctx throws from the captured `pi` handle.
4560
+ // If Pi has replaced us between event-tailer ticks, swallow the throw
4561
+ // and stop the tailer rather than letting an uncaughtException kill
4562
+ // the Pi process.
4563
+ const ok = safeSendMessageFromTimer(
4564
+ pi,
4448
4565
  {
4449
4566
  customType: "supervisor-event",
4450
4567
  content: [{ type: "text", text }],
@@ -4452,6 +4569,9 @@ export function startEventTailer(
4452
4569
  },
4453
4570
  { triggerTurn: true },
4454
4571
  );
4572
+ if (!ok) {
4573
+ stopEventTailer(tailer);
4574
+ }
4455
4575
  };
4456
4576
 
4457
4577
  // ── TP-043: Integration is triggered by triggerSupervisorIntegration() ──
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.30.3",
3
+ "version": "0.30.4",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",