Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 69 additions & 16 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
import {
buildPlannerCompactInstructionBundle,
buildPlannerPostCompactMessage,
choosePlannerMessageDelivery,
clearPlannerCompactionInFlight,
clearPlannerControlledCompact,
collectAutoCompactInstructionSections,
Expand Down Expand Up @@ -1414,6 +1415,18 @@ interface PlannerIdleRuntimeState {
latestCwd: string | null;
checking: boolean;
timer: ReturnType<typeof setInterval> | null;
/**
* Whether the agent is running, read from the most recent event context.
*
* The watchdog ticks on a timer and has no context of its own, but it must
* know: a wake sent as a follow-up while nothing is running is never
* delivered, it just sits in the input queue. Refreshed wherever `latestCwd`
* is, so it is exactly as current as the cwd the tick already relies on, and
* null before the first event — which the caller reads as "assume running",
* the safe direction, since a follow-up sent too early is delivered late
* rather than lost.
*/
isIdle: (() => boolean) | null;
}

export default function piCodePlannerExtension(pi: ExtensionAPI): void {
Expand All @@ -1422,6 +1435,7 @@ export default function piCodePlannerExtension(pi: ExtensionAPI): void {
latestCwd: null,
checking: false,
timer: null,
isIdle: null,
};
const timerRuntime = createPlannerTimerRuntimeState();
installPlannerToolErrorBoundary(pi);
Expand Down Expand Up @@ -3414,6 +3428,11 @@ function registerPlannerTools(
projectPaths,
toolName,
params,
// The model may not clear a compact boundary while the compaction
// it asked for is still running. The planner's own resolve paths
// omit this deliberately — they run when the boundary must be
// released without compacting.
compactionInFlight: isPlannerCompactionInFlight(compactRuntime),
});
const compact = await maybeStartPlannerControlledCompact({
pi,
Expand Down Expand Up @@ -3877,13 +3896,21 @@ function registerPlannerCompactEvents(
preflight,
});
const message = buildPlannerPostCompactMessage({ preflight, sections });
enqueuePlannerPostCompactMessage({
message,
isIdle: ctx.isIdle(),
hasPendingMessages: ctx.hasPendingMessages(),
sendUserMessage: (content, options) =>
pi.sendUserMessage(content, options as never),
});
// Deliver on the next macrotask, not from inside this handler. `compact()`
// disconnects the session from agent events for its whole duration and
// reconnects in a `finally` that has not run yet when `session_compact` is
// emitted — a turn started here would run against a session that is not
// listening, which is how four minutes of real work went missing from a
// measured session file. The same deferral `ctx.compact()` itself uses.
// Idle is re-read at that point because it is only then that it matters.
setTimeout(() => {
enqueuePlannerPostCompactMessage({
message,
isIdle: ctx.isIdle(),
sendUserMessage: (content, options) =>
pi.sendUserMessage(content, options as never),
});
}, 0);
});

// The agent resuming work is our only extension-visible signal that a
Expand Down Expand Up @@ -3923,9 +3950,11 @@ function registerPlannerIdleWatchdog(
): void {
pi.on("session_start", async (_event, ctx) => {
runtime.latestCwd = ctx.cwd;
runtime.isIdle = () => ctx.isIdle();
});
pi.on("tool_call", async (_event, ctx) => {
runtime.latestCwd = ctx.cwd;
runtime.isIdle = () => ctx.isIdle();
});

if (runtime.timer) {
Expand Down Expand Up @@ -3977,7 +4006,21 @@ async function runPlannerIdleWatchdogTick(
markPlannerIdleWakeQueued(state, decision.timestamp),
);
try {
pi.sendUserMessage(decision.message, FOLLOW_UP_MESSAGE_OPTIONS as never);
// A wake exists to reach a model that stopped, so it is normally sent
// into an idle session — and a follow-up queued with nothing running is
// never delivered. Unknown idleness (no event seen yet) is treated as
// running: a follow-up that arrives late still arrives.
const delivery = choosePlannerMessageDelivery({
isIdle: runtime.isIdle?.() === true,
});
if (delivery === "turn") {
pi.sendUserMessage(decision.message);
} else {
pi.sendUserMessage(
decision.message,
FOLLOW_UP_MESSAGE_OPTIONS as never,
);
}
} catch (error) {
await updatePlanState(fs, context.planPaths, (state) => ({
...state,
Expand Down Expand Up @@ -4232,14 +4275,24 @@ async function handlePlannerCompactError(input: {
formatPlannerCompactFailure(input.error, { boundaryResolved: resolved }),
benign ? "info" : "error",
);
if (resolved) {
input.pi.sendUserMessage(
formatPlannerCompactSkipped(
benign ? "session too small to compact" : "compaction failed",
),
FOLLOW_UP_MESSAGE_OPTIONS,
);
}
if (!resolved) return;
const message = formatPlannerCompactSkipped(
benign ? "session too small to compact" : "compaction failed",
);
// A failing ctx.compact() aborts the run on its way in, so this usually lands
// on an idle session — where a follow-up is never delivered. Deferred for the
// same reason the post-compact message is: compact() reconnects the session to
// the agent in a `finally` that has not run yet.
setTimeout(() => {
const delivery = choosePlannerMessageDelivery({
isIdle: input.ctx.isIdle(),
});
if (delivery === "turn") {
input.pi.sendUserMessage(message);
} else {
input.pi.sendUserMessage(message, FOLLOW_UP_MESSAGE_OPTIONS);
}
}, 0);
}

async function maybeStartPlannerStuckCompact(input: {
Expand Down
6 changes: 3 additions & 3 deletions src/runtime/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,14 @@ Runtime domain for planner stages, model-facing status, tool wrappers, timers, r

**Timers / watchdog** — background wake-ups outside the tool-call path.
- `timer.ts` → `PlannerTimerRuntimeState` + reconcile loop; reads `active-plan.ts`, gated by `index.tool-visibility.ts` `isPlanActive`, writes `PlannerTimerState` via `storage/state-store.ts`.
- `idle-watchdog.ts` → reads `state.activeTaskId`/`state.step` → emits a follow-up wake-up message if no activity for `idle.timeoutMinutes`; only fires for `IDLE_EXECUTION_STEPS` while not in a `USER_WAIT_STEPS`/blocked/compact state.
- `idle-watchdog.ts` → reads `state.activeTaskId`/`state.step` → emits a wake-up message if no activity for `idle.timeoutMinutes`; only fires for `IDLE_EXECUTION_STEPS` while not in a `USER_WAIT_STEPS`/blocked/compact state. A pending compact boundary with no compaction in flight is the one case that **bypasses** that gate (a compaction that neither completed nor errored would otherwise freeze the session forever) — so every refusal the gate makes has to be restated in `isStuckCompactBoundary`, which is why it asks `isPlannerWaitingOnUser` rather than only `requiresUserDecision`.

**Misc**
- `compact.ts` → builds the system-instructions bundle injected after a context compact (`PLANNER_COMPACT_MARKER`/`PLANNER_SYSTEM_INSTRUCTIONS_HEADER`), pulling section content from `instructions/manager.ts`.
- `compact.ts` → builds the system-instructions bundle injected after a context compact (`PLANNER_COMPACT_MARKER`/`PLANNER_SYSTEM_INSTRUCTIONS_HEADER`), pulling section content from `instructions/manager.ts`. It also owns **how** a planner message reaches the model: `choosePlannerMessageDelivery` is the single answer for all three senders (post-compact, idle wake, compact-failure notice). `deliverAs: "followUp"` is a *streaming* mode — the agent loop drains that queue only where it would otherwise stop — so with no run in flight a follow-up is never delivered, it just sits in the input queue. A planner-controlled compaction aborts the run on its way in, which is exactly that state. Idle → a plain `sendUserMessage` (always starts a turn); running → follow-up, which is why the mode exists (a late compaction must not interrupt the run it landed in). Unknown idleness counts as running: delivered late beats not delivered. Both compact-path sends are deferred one macrotask, because `ctx.compact()` disconnects the session from agent events and reconnects in a `finally` that has not run when `session_compact` fires.
- **Window management belongs to Pi, not to the planner.** The extension no longer decides *when* to compact for context relief: the window-management compact steps (`compact_discovery`/`spec`/`planning`/`task` and `compact_finalize`) were removed from `PLANNER_STAGE_STEPS`, and the proactive `turn_end` monitor that replaced them (an output-aware token floor) has now been removed too. Reason: the floor was derived from `model.maxTokens` because an extension cannot read Pi's `reserveTokens`, so the two thresholds land wherever the user's settings happen to put them. On a real session (window 131072, `reserveTokens` 24576) our floor sat at 106823 against Pi's 106496 — 0.25% of the window apart, i.e. a guaranteed race instead of the intended separation, and a second compaction started right after Pi's leaves nothing to summarize (`prepareCompaction` measures only what accumulated since the previous cut point, so it throws "Nothing to compact (session too small)" while the window is 96% full). Pi's own auto-compaction sees `reserveTokens` and cannot race itself, so it is the only trigger now. The one surviving compact step is `compact_before_doubt` — a deliberate confidence reset before the doubt audit, not window relief — so `isPlannerCompactEnabled` returns true for it unconditionally and `planner_request_compact` runs it with no token gate at all. Everything that reacts *after* a compaction is untouched and works for Pi's compactions exactly as it did for ours: the `session_before_compact` indicator, the `session_compact` handler, and the post-compact `[SYSTEM_INSTRUCTIONS]` bundle. A legacy `state.json` parked at a removed step is remapped forward by `normalizePlanState` (`storage/state-store.ts`). The `compactBoundaries` (`compact.stage`/`compact.task`) setting and the `state.compactBoundaries` field were removed with the boundaries they gated — a legacy `settings.json` with a `compact` block gets an "unknown key" note, and a legacy `state.json` with a `compactBoundaries` field just ignores it.
- `prefix-watch.ts` → pure probe over the actual provider payload (`before_provider_request`). Tool schemas render INTO the system message, so they sit at the very front of the prompt, and every serving backend reuses exactly one thing: a prefix of bytes it already read. `describePayload` reduces a payload to `headChars`/`toolNames`/`toolChars`/`headKey`; `comparePayloads` reports what the head gained and lost; `PrefixWatch` holds ONE previous shape — no history, no defect log, nothing to report from later — and separates *when* (between runs is expected; between two calls of ONE run the prefix was discarded for nothing) from *who* (`setActiveTools` is a global setter and this extension is not the only writer, so `record()` takes the list WE last set — `plannerLastSetToolNames()`). Only mid-run AND foreign earns an alarm; wired in `index.ts` `registerPlannerPrefixWatch`, one `ctx.ui.notify` per run, nothing sent anywhere. It stays armed because the foreign writer is real, not hypothetical: the same session runs pi-telegram-manager, which answers `session_before_compact` with its own compaction. Ported from that extension's `core/payload-probe.ts`.
- `compact-eta.ts` → pure empirical ETA for the compaction indicator. `estimateCompactionDuration` fits `T(x)=a+b·x` (weighted least squares, recency- and model-weighted; falls back to a through-origin rate model) over `storage/compact-timing-store.ts` history and reports a point ETA + band + variance (`cv` → `stable`/`noisy`/`single`/`none`). `compactionProgressFraction` drives an honest asymptotic bar that fills to a confidence-scaled target at the ETA and never reaches 100% before the real completion event. Wired in `index.ts` `registerPlannerCompactEvents`, gated by `isPlanActive`; the SDK does not stream summary generation, so this learned model is the only real progress signal.
- `skill-library.ts` → `planner_skill_create`/`planner_skill_update`: persists reusable skill markdown + a JSON index (`storage/json.ts`) keyed by source kind (`stuck`/`debug`/`doubt_review`/…); `status.ts` lists active skills from here.
- `question-tools.ts` → `planner_questions_submit`/`planner_questions_resolve`, the user-clarification loop; writes state via `storage/state-store.ts`.
- `workflow-tools.ts` → step-finish exit gates: blocks `finish_step` unless required artifacts/sections exist (via `contracts.ts`, `doubt-review.ts`) and the worktree is clean (`git-state-sync.ts` reality). The last checkpoint before `state-transition.ts` is allowed to advance.
- `workflow-tools.ts` → step-finish exit gates: blocks `finish_step` unless required artifacts/sections exist (via `contracts.ts`, `doubt-review.ts`) and the worktree is clean (`git-state-sync.ts` reality). The last checkpoint before `state-transition.ts` is allowed to advance. It also refuses `complete_compact` while `compactionInFlight` — passed in by the caller, never read from a module, because whether a compaction is running is the host's knowledge and the planner's own recovery paths must be able to release a boundary precisely when one *is* in flight. Without that refusal the compact boundary is decorative: nothing checked that the requested compaction had happened, and a measured session cleared it at once and finished the whole plan on the context the boundary exists to drop.
<!-- pi-code-planner:contracts:end -->
42 changes: 12 additions & 30 deletions src/runtime/compact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,12 @@ describe("planner compact runtime", () => {
expect(message).toContain("Check git state before resuming.");
});

it("queues post-compact instructions behind pending user messages", () => {
it("starts a turn when nothing is running, because a follow-up would not be delivered", () => {
// The defect this replaces: the post-compact instruction was always queued
// as a follow-up, and the agent loop drains that queue only where it would
// otherwise stop. A planner-controlled compaction aborts the run, so the
// message landed in an idle session and sat in the input queue — one
// measured session waited two hours for a keypress.
const calls: Array<{
message: string;
options?: { deliverAs: "followUp" };
Expand All @@ -102,45 +107,22 @@ describe("planner compact runtime", () => {
enqueuePlannerPostCompactMessage({
message: "[SYSTEM_INSTRUCTIONS]\nCall planner_status.",
isIdle: true,
hasPendingMessages: true,
sendUserMessage(message, options) {
calls.push({ message, options });
},
}),
).toBe("followUp");
expect(calls).toEqual([
{
message: "[SYSTEM_INSTRUCTIONS]\nCall planner_status.",
options: { deliverAs: "followUp" },
},
]);
});

it("queues post-compact instructions even when Pi reports idle", () => {
const calls: Array<{
message: string;
options?: { deliverAs: "followUp" };
}> = [];

expect(
enqueuePlannerPostCompactMessage({
message: "[SYSTEM_INSTRUCTIONS]\nCall planner_status.",
isIdle: true,
hasPendingMessages: false,
sendUserMessage(message, options) {
calls.push({ message, options });
},
}),
).toBe("followUp");
).toBe("turn");
expect(calls).toEqual([
{
message: "[SYSTEM_INSTRUCTIONS]\nCall planner_status.",
options: { deliverAs: "followUp" },
options: undefined,
},
]);
});

it("queues post-compact instructions while Pi is still processing", () => {
// The reason follow-up delivery exists and stays: a compaction that finishes
// late must not interrupt the run it landed in.
const calls: Array<{
message: string;
options?: { deliverAs: "followUp" };
Expand All @@ -150,7 +132,6 @@ describe("planner compact runtime", () => {
enqueuePlannerPostCompactMessage({
message: "[SYSTEM_INSTRUCTIONS]\nCall planner_status.",
isIdle: false,
hasPendingMessages: false,
sendUserMessage(message, options) {
calls.push({ message, options });
},
Expand All @@ -165,6 +146,8 @@ describe("planner compact runtime", () => {
});

it("falls back to follow-up when idle state races with active processing", () => {
// Idle was read a moment earlier; a run may have started since. Starting a
// turn into a live run is the one case that throws rather than queueing.
const calls: Array<{
message: string;
options?: { deliverAs: "followUp" };
Expand All @@ -174,7 +157,6 @@ describe("planner compact runtime", () => {
enqueuePlannerPostCompactMessage({
message: "[SYSTEM_INSTRUCTIONS]\nCall planner_status.",
isIdle: true,
hasPendingMessages: false,
sendUserMessage(message, options) {
if (!options) {
throw new Error(
Expand Down
46 changes: 44 additions & 2 deletions src/runtime/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,39 @@ export interface PlannerCompactInstructionSection {
appendPath: string | null;
}

export type PlannerPostCompactDelivery = "followUp";
/**
* How a planner message reaches the model.
*
* `followUp` queues behind a run that is still going; `turn` starts one. They
* are not interchangeable, and picking the wrong one loses the message — see
* {@link choosePlannerMessageDelivery}.
*/
export type PlannerPostCompactDelivery = "followUp" | "turn";

/**
* Which delivery a message needs right now.
*
* `deliverAs: "followUp"` is a *streaming* mode. The agent loop drains the
* follow-up queue only where it would otherwise stop, so a follow-up queued
* while a run is going is delivered at the end of it — which is exactly what
* this wants, and why the mode was introduced: a compaction that finished late
* used to drop its instruction into a turn that had already moved on.
*
* But with no run in flight there is nothing to queue behind. The message sits
* in the input queue as if the user had typed it and never pressed enter. That
* is the observed failure: a planner-controlled compaction aborts the run, so
* by the time `session_compact` fires the agent is idle, the post-compact
* instruction is queued, and the session waits — one measured session sat two
* hours that way.
*
* Idle is therefore the whole question, and `sendUserMessage` with no options
* always starts a turn.
*/
export function choosePlannerMessageDelivery(input: {
isIdle: boolean;
}): PlannerPostCompactDelivery {
return input.isIdle ? "turn" : "followUp";
}

export function createPlannerCompactRuntimeState(): PlannerCompactRuntimeState {
return {
Expand Down Expand Up @@ -251,12 +283,22 @@ export function buildPlannerPostCompactMessage(input: {
export function enqueuePlannerPostCompactMessage(input: {
message: string;
isIdle: boolean;
hasPendingMessages: boolean;
sendUserMessage: (
message: string,
options?: { deliverAs: "followUp" },
) => void;
}): PlannerPostCompactDelivery {
const delivery = choosePlannerMessageDelivery({ isIdle: input.isIdle });
if (delivery === "turn") {
try {
input.sendUserMessage(input.message);
return "turn";
} catch {
// Idle was read a moment ago and a run may have started since. Starting a
// turn into a live run is the one case that throws instead of queueing,
// so fall back to the queue: delivered late beats not delivered.
}
}
input.sendUserMessage(input.message, { deliverAs: "followUp" });
return "followUp";
}
Expand Down
20 changes: 20 additions & 0 deletions src/runtime/idle-watchdog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,26 @@ describe("planner idle watchdog", () => {
).toMatchObject({ action: "disabled" });
});

it("does not rescue a pending compact where the next move is the user's", () => {
// The rescue bypasses the whole idle gate, so it has to restate every reason
// that gate would have refused for. `done` sets no requiresUserDecision flag
// — it has to be recognised through isPlannerWaitingOnUser, or a boundary
// left pending here would wake the model while the user is the one to act.
for (const position of [
{ stage: "done" as const, step: "await_user_acceptance" as const },
{ stage: "intake" as const, step: "await_goal_approval" as const },
]) {
expect(
evaluatePlannerIdleWake({
state: { ...stuckCompact(), ...position },
settings,
now: 601_000,
compactionInFlight: false,
}),
).toMatchObject({ action: "disabled" });
}
});

it("waits for the timeout before rescuing a stuck compact", () => {
expect(
evaluatePlannerIdleWake({
Expand Down
Loading