From ea3d167dba8eb256440ed27a923b943eb5c07491 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Sun, 20 Sep 2026 23:02:15 +0200 Subject: [PATCH 1/3] feat(agents): preserve parent prompt cache during background work --- assets/orchestrator-delegation.md | 2 +- docs/readme-reference.md | 10 ++ extensions/gentle-agents.ts | 9 +- lib/background-cache-warming.ts | 38 ++++++++ odd/tasks/background-cache-warming.md | 125 +++++++++++++++++++++++++ tests/background-cache-warming.test.ts | 60 ++++++++++++ tests/gentle-agents.test.ts | 71 +++++++++++++- 7 files changed, 312 insertions(+), 3 deletions(-) create mode 100644 lib/background-cache-warming.ts create mode 100644 odd/tasks/background-cache-warming.md create mode 100644 tests/background-cache-warming.test.ts diff --git a/assets/orchestrator-delegation.md b/assets/orchestrator-delegation.md index ae5711f0c..bf8c1a0c1 100644 --- a/assets/orchestrator-delegation.md +++ b/assets/orchestrator-delegation.md @@ -203,7 +203,7 @@ When the policy is on and `subagent_run` is available: - The runtime already defaults `subagent_run` to `mode: "background"` under this policy in interactive and RPC sessions, so omit `mode` for ordinary delegation. It returns a task id at once; the terminal stays free and the human keeps typing. Pass a `label` of three to six words naming the work. - A child `agent_end` retains its latest answer but is not completion: Pi may still retry, compact, or run a queued follow-up. Treat the task as finished only at `agent_settled`; only then release its queue slot, publish its background result, or terminate it. If it exits first, report failure with its retained answer as diagnostics. -- When a background task settles, its result arrives as a message in this session (custom type `gentle-agents.result`, one per task) and starts a new turn if you are idle. Wait for it: end the turn once launches and any non-overlapping work are done. Never poll, sleep, or call `subagent_status`/`subagent_result` for completion. +- When a background task settles, its result arrives as a message in this session (custom type `gentle-agents.result`, one per task) and starts a new turn if you are idle. Wait for it: end the turn once launches and any non-overlapping work are done. Never sleep or periodically poll `subagent_status`/`subagent_result` for completion or cache maintenance. Retain the task ID. Use `subagent_status` only at a real orchestration decision boundary: user-requested inspection, relevant scope change, input request, or suspected abnormal behavior. Never relaunch equivalent work merely because it is queued or running. Cache warming belongs to Pi's native runtime, never to model-driven maintenance turns. - Do not claim an implementation ready or RDD-ready while its required verification or correction follow-up remains queued. Run the required focused verification before that claim, and retain legitimate post-correction verification. This does not invent a universal full-suite requirement or make a receipt a delivery gate. - Use `mode: "task"` only when the subagent must ask the human something mid-flight (task-mode dialogs reach the human; background dialogs are dismissed) or when the human asked to wait. - Launch as many independent tasks as the work has; the runner queues beyond `max_concurrency`. Do not duplicate launches or work, and do not overlap files or topics. Never run parallel writers in one worktree. diff --git a/docs/readme-reference.md b/docs/readme-reference.md index 9601cc102..8e56e942f 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -842,6 +842,16 @@ One limitation is worth stating. When a pinned profile omits an agent, that agen Startup installs and refreshes only delegation and review assets. SDD assets are installed/refreshed on demand; status and doctor report never-installed SDD assets as informational, while missing or stale assets from an existing installation identify their owner-specific repair command. User and project overrides are reported separately from package drift. Package refresh preserves overrides; explicit saved model settings may still update existing SDD or custom-agent routing at startup. +### Native cache warming (Pi 0.86.1+) + +To allow warming while the parent waits for background results, explicitly set `"cacheWarming": "idle"` in Pi's `settings.json` (user scope: `~/.pi/agent/settings.json`, or project scope: `.pi/settings.json`). Gentle Shell never changes this setting. Native `"streaming"` mode stops when the agent settles: no idle decision is offered for this hook to override. `"off"` remains an opt-out. + +Pi owns provider cache-lifetime eligibility, safe replay, scheduling, and the fixed 30-minute idle / one-hour streaming horizons. Unknown provider lifetimes do not get inferred. Real provider requests replace Pi's schedule; Gentle Shell adds no timer or maintenance message. Refresh usage stays outside model context. Warming is best-effort, not a guarantee of a future cache hit. + +Ordinary idle decisions retain Pi's 15% continuation assumption. When the active parent owns queued or running background tasks, Gentle Shell treats continuation probability as 1, but still requires estimated cache-miss savings minus refresh cost to be at least $0.05. Restored, foreign-session, foreground, waiting-for-input, and finished tasks do not strengthen that decision. This only overrides candidates Pi actually offers; it never starts, inspects, polls, steers, or duplicates children. + +Completion remains push-driven through `gentle-agents.result`. Retain the task ID, end the parent turn when independent work is done, and never sleep or periodically poll status/results to maintain cache or detect completion. Status inspection is for a concrete orchestration decision, not a heartbeat. + ### Background subagents policy Background delegation requires a live interactive/RPC parent and is rejected in `pi -p`, even when the policy is on. Use task mode for bounded print-mode work. diff --git a/extensions/gentle-agents.ts b/extensions/gentle-agents.ts index 6d68efddf..ea67cb2fd 100644 --- a/extensions/gentle-agents.ts +++ b/extensions/gentle-agents.ts @@ -17,6 +17,7 @@ import { invalidateSidebar } from "../lib/shell-sidebar-layout.ts"; import { createCompletionQueue } from "../lib/agents-completion-delivery.ts"; import { AGENT_MODE, discoverAgents, parseAgentDefinition, loadAgentsConfig, resolveAgentProfile, withPinnedModelProfiles, type AgentDefinition, type AgentMode } from "../lib/agents-config.ts"; import { resolveBackgroundSubagentsPolicy } from "../lib/background-subagents-policy.ts"; +import { installBackgroundCacheWarming } from "../lib/background-cache-warming.ts"; import { isFinished, TASK_EVENT, TASK_STATUS, TaskStore, type AskRequest, type TaskRecord } from "../lib/agents-protocol.ts"; import { AgentRunner, piCommand, abortReasonText, plannedCommands, type RemediationPlan, type RemediationScope, REMEDIATION_PLAN_ENV, parseRemediationPlan, type AskAnswer, type RunnerDeps, type SddChangeSelection, type TaskRequest } from "../lib/agents-runner.ts"; import { ChildMessenger, type IpcEndpoint } from "../lib/agents-messaging.ts"; @@ -529,6 +530,12 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = // started before /new or /resume stays in the store and comes back with // its session. Before the first session_start there is nothing to scope by. const activeSessionId = (): string | undefined => (sessions === undefined ? undefined : sessions.getSessionId() ?? ""); + // Pi 0.86.1 adds this event; the package's pinned 0.85.1 types predate it. + installBackgroundCacheWarming(pi as unknown as Parameters[0], () => ({ + sessionId: activeSessionId(), + ownedTaskIds, + tasks: store.list(activeSessionId()), + })); const visibleTasks = (): TaskRecord[] => store.list(activeSessionId()); type SessionTransport = { generation: number; sessionId: string; sessionManager: ExtensionContext["sessionManager"]; client: SessionTransportClient; listener: SessionTransportListener; registry: SessionTransportRegistry; close(): Promise }; let transportGeneration = 0; @@ -1168,7 +1175,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = if (observe) metricTasks.set(task.id, metrics); ownedTaskIds.add(task.id); store.subscribe(task.id, () => { publishActivity(); requestRender(); }); - if (request.mode === AGENT_MODE.BACKGROUND) return text(`Started ${task.agent} in the background as task ${task.id}. Use subagent_status or subagent_result with that id.`, taskDetails(task)); + if (request.mode === AGENT_MODE.BACKGROUND) return text(`Started ${task.agent} in the background as task ${task.id}. Retain that id; completion is pushed automatically. Never sleep or periodically poll subagent_status/subagent_result for completion or cache maintenance. Inspect status only at a real orchestration decision boundary; never relaunch equivalent queued/running work.`, taskDetails(task)); // A tool call aborted by the host (a human interrupting the turn, a timeout) // would otherwise leave the child running and end the call with no result and // no recorded reason. Cancel through the runner so the lifecycle runs and the diff --git a/lib/background-cache-warming.ts b/lib/background-cache-warming.ts new file mode 100644 index 000000000..a5c59e41c --- /dev/null +++ b/lib/background-cache-warming.ts @@ -0,0 +1,38 @@ +import type { TaskRecord } from "./agents-protocol.ts"; + +const ACTION = { WARM: "warm", STOP: "stop" } as const; +type WarmingAction = (typeof ACTION)[keyof typeof ACTION]; +interface WarmingDecision { + warmCost: number; + missCost: number; + continuationProbability: number; + action: WarmingAction; +} +interface WarmingOverride { action: WarmingAction } +interface WarmingEvents { + on(event: "cache_warming_decision", handler: (event: WarmingDecision) => WarmingOverride | undefined): unknown; +} +export interface WarmingState { + sessionId: string | undefined; + ownedTaskIds: ReadonlySet; + tasks: Array>; +} + +// Pi 0.86.1's native minimum expected savings, in dollars. Ownership raises +// continuation probability to 1, not permission to spend without a benefit. +const MINIMUM_EXPECTED_SAVINGS = 0.05; + +export function installBackgroundCacheWarming(pi: WarmingEvents, state: () => WarmingState): void { + pi.on("cache_warming_decision", (event) => { + const { sessionId, ownedTaskIds, tasks } = state(); + if (!sessionId || !tasks.some(task => task.parentSessionId === sessionId && + ownedTaskIds.has(task.id) && task.mode === "background" && + (task.status === "queued" || task.status === "running"))) return; + // Only native candidates arrive here: Pi owns opt-out, replay safety, + // provider TTLs, fixed horizons, scheduling and usage outside context. + const economic = Number.isFinite(event.warmCost) && event.warmCost >= 0 && + Number.isFinite(event.missCost) && event.missCost > 0 && + event.missCost - event.warmCost >= MINIMUM_EXPECTED_SAVINGS; + return { action: economic ? ACTION.WARM : ACTION.STOP }; + }); +} diff --git a/odd/tasks/background-cache-warming.md b/odd/tasks/background-cache-warming.md new file mode 100644 index 000000000..294ec172d --- /dev/null +++ b/odd/tasks/background-cache-warming.md @@ -0,0 +1,125 @@ +# Background Cache Warming + +Issue: https://github.com/Gentleman-Programming/gentle-shell/issues/1278 +Related umbrella: https://github.com/Gentleman-Programming/gentle-ai/issues/4799 + +## Objective + +Use Pi's native prompt-cache warming in Gentle Shell so expensive parent-session prefixes remain warm while owned background subagents run and during bounded ordinary idle periods, without blocking the parent or adding maintenance messages to model context. + +## Problem + +Gentle Agents intentionally runs children in the background so the user can continue talking to the parent. The parent may then remain idle longer than a provider's prompt-cache lifetime. A later child completion or user prompt can pay a cold-cache miss even though Pi 0.86.1 already has a cost-aware native cache warmer. + +Gentle Shell currently does not integrate with `cache_warming_decision` or clearly expose the native `cacheWarming: "idle"` mode. + +## Why + +Native warming refreshes a known provider cache shortly before expiry with one output token, records usage outside model context, and automatically reschedules after real provider requests. It is safer and cheaper than model-driven status polling, artificial sleeps, or hidden maintenance turns. + +## Scope + +- Integrate Gentle Agents with Pi's native `cache_warming_decision` event. +- Preserve Pi's native ordinary idle policy, including its 15% continuation assumption, economic threshold, provider lifetime checks, and 30-minute idle bound. +- While the active parent session owns queued or running background tasks, treat eventual continuation as likely and allow warming only when the native cost evidence still makes it economically worthwhile. +- Ensure real user/provider activity naturally resets scheduling through Pi's native warmer; do not add a parallel timer. +- Surface or document how to enable native idle warming without silently overriding an explicit user opt-out. +- Keep background completion push-driven. Preserve the prohibition on sleeping or periodic status polling for completion/cache maintenance, while allowing `subagent_status` only at a real orchestration decision boundary (user-requested inspection, relevant scope change, input request, or suspected abnormal behavior). +- Add deterministic contract and integration tests. + +## Out of Scope + +- Gentle AI changes. +- Model-driven polling, `sleep`, long-poll tools, or blocking the parent. +- Automatic semantic supervision or steering of children. +- Provider cache lifetime inference where Pi has no declared `promptCache` metadata. +- Extending native warming beyond Pi's bounded idle/active horizons. + +## Constraints + +- Technical artifacts in English. +- Preserve background completion delivery and task ownership semantics. +- Respect explicit `cacheWarming: "off"` and unknown provider cache lifetimes. +- No hidden settings mutation. +- Never relaunch equivalent child work merely because an existing task remains queued or running. +- No commit, push, PR, merge, or issue mutation without an explicit user request. +- Keep the implementation under the existing review-workload heuristic; forecast is under 200 authored changed lines. + +## TDD + +- Mode: strict TDD enabled. +- Source: `openspec/config.yaml` and persisted project testing capabilities. +- Focused runner: `node --experimental-strip-types --test tests/background-cache-warming.test.ts` (deterministic in-memory event tests plus source/prompt contract checks; no provider calls). +- Required cycle: RED -> GREEN -> REFACTOR. +- Final checks: focused tests, `pnpm run typecheck`, and `git diff --check`; run broader tests when the touched runtime surface requires them. + +## Tasks + +- [x] **T1 — Specify the native-warming contract (delegated).** Add RED tests proving the integration distinguishes owned live background work from ordinary idle, preserves economic gating, and never blocks or injects model-context messages. +- [x] **T2 — Implement the Gentle Shell integration (delegated).** Wire the smallest Pi-native decision hook and user-facing configuration guidance while preserving explicit opt-out and native lifecycle bounds. +- [x] **T3 — Verify behavior and regression boundaries (delegated verification).** Run focused tests, typecheck, diff check, and an independent read-only verification if required by native risk assessment. +- [x] **T4 — Close the lifecycle coverage gap (delegated correction).** Add one extension-level behavioral test that fires the registered warming decision before launch, during owned background work, after session replacement/restoration, and after completion; remove reliance on wiring regex as the only integration proof. + +## Acceptance Criteria + +- A live owned background task can strengthen a native warming decision without starting an orchestrator turn. +- Ordinary idle behavior remains Pi-native and bounded; no Gentle-owned recurring timer exists. +- A real user/provider request resets Pi's native warming schedule without Gentle-owned bookkeeping. +- Explicit warming opt-out and unsupported provider lifetimes remain no-op paths. +- No child task is launched, duplicated, periodically polled, inspected, or steered by cache warming. +- `subagent_status` remains available only for concrete orchestration decisions; it is never a cache heartbeat. +- Cache-warming usage remains outside LLM context and existing completion delivery stays unchanged. +- Required tests and checks pass with observed evidence recorded below. + +## Route + +- T1/T2: delegated direct writer, triggered by multiple non-trivial files and implementation-preparation reads. +- T3: delegated verification, triggered by command-running verification policy. +- T4: delegated direct test-only correction; production code is unchanged. + +## Delivery Strategy + +- Strategy: `ask-on-risk`. +- Forecast: under 200 authored changed lines, no chained PR expected. +- Delivery remains a separate user decision. + +## Progress + +- Exploration complete: Pi 0.86.1 provides native `cacheWarming: "idle"` and `cache_warming_decision`; Gentle Shell currently has no integration. +- Prompt decision confirmed: keep completion push-driven; prohibit sleep/periodic polling; permit status inspection only at a concrete orchestration decision boundary. +- Isolated worktree created at `/Users/alanbuscaglia/work/gentle-pi-worktrees/background-cache-warming` on `feat/background-cache-warming` from `origin/main` commit `40e91351`. +- Tracking created before source/test writes. +- T1/T2 implemented: native decision-only event handler, current-session ownership/economic tests, explicit idle opt-in documentation, and push-driven/no-polling prompt guidance. No source-mutating formatter or delivery operation ran. +- Pi 0.85.1 remains the package's pinned development API. One documented type boundary admits the 0.86.1 event without dependency changes; older runtimes do not supply native candidates. +- Independent T3 verifier passed every acceptance criterion and all required commands. It found one low-severity coverage gap: the actual extension lifecycle is represented only by wiring regex plus separate helper/ownership tests. +- T4 closed the lifecycle gap with the actual registered extension event, runtime child launch/settlement, active-session switching, and a live-looking unowned record seeded through the real TaskStore restore method. T3 finalized using the prior independent verification plus the passing correction checks. No runtime defect was observed. + +## Checks + +- RED: `node --experimental-strip-types --test tests/background-cache-warming.test.ts` exited 1: `ERR_MODULE_NOT_FOUND` for the absent `lib/background-cache-warming.ts` integration (1 failed test file). Captured before implementation. +- API confirmed from installed Pi 0.86.1 `docs/extensions.md` and `dist/core/cache-warmer.js`: decision supplies `warmCost`, `missCost`, `continuationProbability`, and `action`; return an action override only. Native eligibility checks precede emission; streaming mode stops at settlement, idle has a 30-minute bound, and real requests replace the schedule. +- Ownership confirmed: Gentle Agents tracks `ownedTaskIds` separately from restored history and filters tasks by `activeSessionId()`. Only owned queued/running background records qualify. +- GREEN: `node --experimental-strip-types --test tests/background-cache-warming.test.ts` passed all 3 tests after implementation and again after type corrections. +- TRIANGULATE: queued/running ownership, missing/foreign session, restored/unowned records, task mode, waiting/terminal states, exact savings threshold, invalid/non-finite costs, and unchanged ordinary idle action covered. The registered callback is synchronous and has no timer, provider, messaging, or child-runner capability. Extension wiring and prompt guidance have source-contract assertions; this is not an end-to-end provider test. +- `pnpm run typecheck`: initial run failed on the pre-0.86.1 API type and a fixture status type; both corrected. Final run passed the existing ratchet with 196 recorded diagnostics and no regressions (not a clean compiler baseline). +- Environment side effect: the first explicitly requested `pnpm run typecheck` auto-installed 195 packages and executed postinstall, generating `node_modules/` and `.gentle-ai/v3.4.0/gentle-ai` outside the edit surfaces. No installer was requested separately; generated state was preserved, not deleted. Subsequent run performed only typechecking. Tracked diff remains within allowed surfaces. +- `git diff --check`: passed. +- Independent verifier: focused 3/3, Gentle Agents 111/111, type ratchet passed (196 baseline diagnostics, no regressions), diff-check clean; pre/post status unchanged. The one low lifecycle-integration coverage gap was subsequently closed by T4. +- No live provider/cache-hit check ran. The verifier's exact BSD-format `stat` metadata command failed under the installed `stat` implementation; no substitution, cleanup, or candidate mutation ran. + +### T4 correction evidence + +- RED: `node --experimental-strip-types --test tests/gentle-agents.test.ts` exited 1 (111 passed, 1 failed). The new event-level assertion received `undefined` instead of `[undefined]` because `fakePi.fire()` discarded registered callback returns. This was a test-harness observability failure, not a production behavior defect. No production RED is claimed. +- GREEN: the fake event dispatcher now collects callback results; the new lifecycle test passes against unchanged production code. It verifies ordinary behavior before launch, warming for an owned running child (including after `agent_end` but before settlement), no strengthening for a foreign active session or restored/unowned running record, and ordinary behavior after settlement/shutdown. +- Side-effect checks around every decision assert unchanged launch count, scheduler calls, status/result tool calls, child RPC/IPC traffic, completion messages, and persisted context entries, plus zero timeout/interval calls. Settlement still pushes exactly one `gentle-agents.result` with the original task ID and `triggerTurn: true`. +- REFACTOR: removed the extension source-wiring regex. Pure helper and prompt tests remain, with a supplemental no-maintenance-capability source guard; actual extension lifecycle behavior is now the primary integration proof. Queued-state decision coverage remains in the helper test. +- `node --experimental-strip-types --test tests/background-cache-warming.test.ts`: passed 3/3. +- `node --experimental-strip-types --test tests/gentle-agents.test.ts`: passed 112/112, including the new behavioral test. Fixture Git diagnostics (`not a git repository` / missing fixture cwd) appeared on stderr without test failures. +- `git diff --check`: passed. +- Typecheck intentionally not rerun: T4 changes tests and this document only. No install, production edit, or delivery operation was performed for T4. +- Final independent verification after T4: combined focused suites passed 115/115; `git diff --check` passed; tracked and untracked candidate diff was inspected; no defects found. It confirmed `fakePi.fire()` still propagates handler failures and preserves lifecycle bookkeeping, test-scoped mocks/timers do not leak, the registered production callback is exercised, and production code stayed unchanged after the first review. The prior LOW coverage finding is closed. +- Proof boundary: no live provider/cache-hit or full Pi-host lifecycle run was performed. Session replacement is represented by active-session identity changes and restoration by real `TaskStore.restore`; shutdown is covered after settlement. + +## Next Step + +Candidate implementation and verification are complete. The user authorized PR and merge; issue #1278 is approved. Next: create the implementation work-unit commit, run the applicable native delivery checks, push, open the PR, wait for automated checks, and merge. diff --git a/tests/background-cache-warming.test.ts b/tests/background-cache-warming.test.ts new file mode 100644 index 000000000..dcf400479 --- /dev/null +++ b/tests/background-cache-warming.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFileSync } from "node:fs"; +import { installBackgroundCacheWarming, type WarmingState } from "../lib/background-cache-warming.ts"; + +test("native warming decisions use only current owned live background work", () => { + let handler: Parameters[0]["on"]>[1]; + let state: WarmingState = { sessionId: "parent", ownedTaskIds: new Set(["task"]), tasks: [ + { id: "task", parentSessionId: "parent", mode: "background", status: "queued" }, + ] }; + installBackgroundCacheWarming({ on(event, callback) { + assert.equal(event, "cache_warming_decision"); + handler = callback; + } }, () => state); + const candidate = { warmCost: 0.01, missCost: 0.1, continuationProbability: 0.15, action: "stop" as const }; + assert.deepEqual(handler!(candidate), { action: "warm" }); + state.tasks[0].status = "running"; + assert.deepEqual(handler!(candidate), { action: "warm" }); + for (const status of ["waiting", "completed", "failed", "cancelled", "timed_out"] as const) { + state.tasks[0].status = status; + assert.equal(handler!(candidate), undefined, status); + } + state.tasks[0].status = "running"; + for (const patch of [ + { sessionId: undefined }, { sessionId: "other" }, { ownedTaskIds: new Set() }, + { tasks: [] }, { tasks: [{ ...state.tasks[0], mode: "task" }] }, + ]) { + const original = state; + state = { ...state, ...patch }; + assert.equal(handler!(candidate), undefined); + state = original; + } + for (const warmCost of [0.06, 1, NaN, Infinity, -1]) { + assert.deepEqual(handler!({ ...candidate, warmCost }), { action: "stop" }); + } + for (const missCost of [0, -1, NaN, Infinity]) { + assert.deepEqual(handler!({ ...candidate, missCost }), { action: "stop" }); + } + assert.deepEqual(handler!({ ...candidate, warmCost: 0, missCost: 0.05 }), { action: "warm" }); + assert.deepEqual(handler!({ ...candidate, warmCost: 0, missCost: 0.049 }), { action: "stop" }); + state.tasks = []; + assert.equal(handler!({ ...candidate, action: "warm" }), undefined, "ordinary idle keeps native action"); +}); + +// Supplemental architecture guard; the extension lifecycle is exercised in gentle-agents.test.ts. +test("warming helper has no maintenance capabilities", () => { + const helper = readFileSync(new URL("../lib/background-cache-warming.ts", import.meta.url), "utf8"); + assert.doesNotMatch(helper, /setTimeout|setInterval|sendMessage|sendUserMessage|subagent_|runner\./); +}); + +test("background prompt prohibits cache polling and documents explicit native idle opt-in", () => { + const prompt = readFileSync(new URL("../assets/orchestrator-delegation.md", import.meta.url), "utf8"); + assert.match(prompt, /completion or cache maintenance/); + assert.match(prompt, /user-requested inspection, relevant scope change, input request, or suspected abnormal behavior/); + assert.match(prompt, /Never relaunch equivalent work merely because it is queued or running/); + const docs = readFileSync(new URL("../docs/readme-reference.md", import.meta.url), "utf8"); + assert.match(docs, /"cacheWarming": "idle"/); + assert.match(docs, /\$0\.05/); + assert.match(docs, /"streaming".*no idle decision/s); +}); diff --git a/tests/gentle-agents.test.ts b/tests/gentle-agents.test.ts index f00fa1530..5867f48f3 100644 --- a/tests/gentle-agents.test.ts +++ b/tests/gentle-agents.test.ts @@ -147,8 +147,10 @@ function fakePi() { await fire("session_shutdown", ctx, { reason: "quit" }); }; const fire = async (event: string, ctx: ExtensionContext, payload: unknown = {}) => { + const results: unknown[] = []; try { - for (const handler of handlers.get(event) ?? []) await handler(payload, ctx); + for (const handler of handlers.get(event) ?? []) results.push(await handler(payload, ctx)); + return results; } finally { if (event === "session_start") { activeSession = ctx; @@ -237,6 +239,73 @@ function deps(): { deps: Partial; children: FakeChild[]; spawned: st }; } +test("cache warming follows actual Gentle Agents ownership and completion lifecycle", async (t) => { + const h = fakePi(); + const runtime = deps(); + let store: TaskStore | undefined; + const list = TaskStore.prototype.list; + t.mock.method(TaskStore.prototype, "list", function (this: TaskStore, ...args: Parameters) { + store = this; + return list.apply(this, args); + }); + const scheduled = t.mock.fn(() => () => {}); + runtime.deps.schedule = scheduled; + gentleAgents(h.pi, {}, runtime.deps); + const { ctx } = fakeContext(); + let sessionId = "warming-parent"; + ctx.sessionManager.getSessionId = () => sessionId; + await h.fire("session_start", ctx); + const candidate = { type: "cache_warming_decision", warmCost: 0.01, missCost: 0.1, continuationProbability: 0.15, action: "stop" }; + const run = t.mock.method(AgentRunner.prototype, "run"); + const status = t.mock.method(h.tools.get("subagent_status")!, "execute"); + const result = t.mock.method(h.tools.get("subagent_result")!, "execute"); + const decide = async (expected: unknown) => { + const before = [runtime.spawned.length, run.mock.callCount(), scheduled.mock.callCount(), status.mock.callCount(), result.mock.callCount()]; + const sent = [...h.sent], entries = [...h.entries]; + const childTraffic = runtime.children.map(child => JSON.stringify([child.written, child.sent, child.killed])); + const timeout = t.mock.method(globalThis, "setTimeout"); + const interval = t.mock.method(globalThis, "setInterval"); + try { + assert.deepEqual(await h.fire("cache_warming_decision", ctx, candidate), [expected]); + assert.equal(timeout.mock.callCount(), 0); + assert.equal(interval.mock.callCount(), 0); + } finally { timeout.mock.restore(); interval.mock.restore(); } + assert.deepEqual([runtime.spawned.length, run.mock.callCount(), scheduled.mock.callCount(), status.mock.callCount(), result.mock.callCount()], before); + assert.deepEqual(h.sent, sent, "no maintenance or duplicate completion message"); + assert.deepEqual(h.entries, entries, "no maintenance context entry"); + assert.deepEqual(runtime.children.map(child => JSON.stringify([child.written, child.sent, child.killed])), childTraffic, "no child inspection, steering, or cancellation"); + }; + await decide(undefined); + const launch = await h.tools.get("subagent_run")!.execute("warming-launch", { agent: "explore", task: "Map warming", mode: "background" }, undefined, undefined, ctx); + await tick(); + const taskId = (launch.details.gentleAgents as { taskId: string }).taskId; + assert.equal(runtime.children.length, 1); + await decide({ action: "warm" }); + // The same live owned child is foreign when the active session changes. + sessionId = "other-parent"; + await decide(undefined); + sessionId = "warming-parent"; + await decide({ action: "warm" }); + runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "mapped" }] }] }); + await tick(); + await decide({ action: "warm" }); + assert.equal(h.sent.length, 0, "agent_end is not settlement"); + runtime.children[0].emit({ type: "agent_settled" }); + await tick(); + await decide(undefined); + assert.equal(h.sent.length, 1); + assert.equal(h.sent[0].message.customType, "gentle-agents.result"); + assert.match(JSON.stringify(h.sent[0].message), new RegExp(taskId)); + assert.equal(h.sent[0].options.triggerTurn, true); + assert.equal(run.mock.callCount(), 1, "warming never launches equivalent work"); + // Seed the actual history-restore path with a stale live-looking record: + // sharing the parent ID and running status must not confer ownership. + assert.equal(store!.restore({ ...store!.get(taskId)!, id: "warming-restored", status: TASK_STATUS.RUNNING }, emptyThread()), true); + await decide(undefined); + await h.fire("session_shutdown", ctx); + await decide(undefined); +}); + const PRINT_BACKGROUND_ERROR = "Background subagents are unavailable in print mode: pi -p exits before a parent session can receive results. Use task mode, RPC mode, or interactive Pi."; for (const continuation of [false, true]) { From 411789ca2d44376fb6ce5811f903267cd520e857 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Sun, 20 Sep 2026 23:02:28 +0200 Subject: [PATCH 2/3] docs(odd): record background cache warming delivery --- odd/tasks/background-cache-warming.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odd/tasks/background-cache-warming.md b/odd/tasks/background-cache-warming.md index 294ec172d..fce9da927 100644 --- a/odd/tasks/background-cache-warming.md +++ b/odd/tasks/background-cache-warming.md @@ -122,4 +122,4 @@ Native warming refreshes a known provider cache shortly before expiry with one o ## Next Step -Candidate implementation and verification are complete. The user authorized PR and merge; issue #1278 is approved. Next: create the implementation work-unit commit, run the applicable native delivery checks, push, open the PR, wait for automated checks, and merge. +Candidate implementation and verification are complete. The user authorized PR and merge; issue #1278 is approved. Implementation work-unit commit: `ea3d167d` (`feat(agents): preserve parent prompt cache during background work`). Next: run the applicable native delivery checks, push, open the PR, wait for automated checks, and merge. From 742695c7fbf63863bc1817543deffa8d4ea8cacf Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Sun, 20 Sep 2026 23:23:09 +0200 Subject: [PATCH 3/3] fix(test): preserve shutdown callback return type --- odd/tasks/background-cache-warming.md | 11 ++++++++++- tests/gentle-agents.test.ts | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/odd/tasks/background-cache-warming.md b/odd/tasks/background-cache-warming.md index fce9da927..350ca4b2d 100644 --- a/odd/tasks/background-cache-warming.md +++ b/odd/tasks/background-cache-warming.md @@ -59,6 +59,7 @@ Native warming refreshes a known provider cache shortly before expiry with one o - [x] **T2 — Implement the Gentle Shell integration (delegated).** Wire the smallest Pi-native decision hook and user-facing configuration guidance while preserving explicit opt-out and native lifecycle bounds. - [x] **T3 — Verify behavior and regression boundaries (delegated verification).** Run focused tests, typecheck, diff check, and an independent read-only verification if required by native risk assessment. - [x] **T4 — Close the lifecycle coverage gap (delegated correction).** Add one extension-level behavioral test that fires the registered warming decision before launch, during owned background work, after session replacement/restoration, and after completion; remove reliance on wiring regex as the only integration proof. +- [x] **T5 — Correct the CI type regression (inline correction + delegated verification).** Preserve awaited shutdown cleanup while adapting the existing `Promise` callback to the result-returning fake event dispatcher. ## Acceptance Criteria @@ -76,6 +77,7 @@ Native warming refreshes a known provider cache shortly before expiry with one o - T1/T2: delegated direct writer, triggered by multiple non-trivial files and implementation-preparation reads. - T3: delegated verification, triggered by command-running verification policy. - T4: delegated direct test-only correction; production code is unchanged. +- T5: inline one-line test-harness correction after CI RED, followed by delegated command verification. ## Delivery Strategy @@ -120,6 +122,13 @@ Native warming refreshes a known provider cache shortly before expiry with one o - Final independent verification after T4: combined focused suites passed 115/115; `git diff --check` passed; tracked and untracked candidate diff was inspected; no defects found. It confirmed `fakePi.fire()` still propagates handler failures and preserves lifecycle bookkeeping, test-scoped mocks/timers do not leak, the registered production callback is exercised, and production code stayed unchanged after the first review. The prior LOW coverage finding is closed. - Proof boundary: no live provider/cache-hit or full Pi-host lifecycle run was performed. Session replacement is represented by active-session identity changes and restoration by real `TaskStore.restore`; shutdown is covered after settlement. +### T5 CI correction evidence + +- CI RED on PR #1279: `verify` → `Type check` ran `pnpm run typecheck` and exited 1 with `TS2322`: the existing shutdown callback promised `void`, while the corrected fake dispatcher now returns `Promise`. +- Root cause: `shutdown.push(() => h.fire("session_shutdown", ctx))` forwarded the dispatcher result into `Array<() => Promise>`. This was candidate-caused test-harness typing, not a production runtime defect. +- Correction: `shutdown.push(async () => { await h.fire("session_shutdown", ctx); });` preserves awaited cleanup and intentionally discards the event-result array. +- Delegated GREEN: `pnpm run typecheck` passed the ratchet with 196 baseline diagnostics and 3 improved file/code pairs; combined focused/runtime suites passed 115/115; `git diff --check` passed. + ## Next Step -Candidate implementation and verification are complete. The user authorized PR and merge; issue #1278 is approved. Implementation work-unit commit: `ea3d167d` (`feat(agents): preserve parent prompt cache during background work`). Next: run the applicable native delivery checks, push, open the PR, wait for automated checks, and merge. +Issue #1278 is approved and PR #1279 is open. Implementation work-unit commit: `ea3d167d` (`feat(agents): preserve parent prompt cache during background work`). The first CI run exposed T5 and the correction is verified locally. Next: commit and push T5, renew native review for the changed candidate, wait for all automated checks, and merge. diff --git a/tests/gentle-agents.test.ts b/tests/gentle-agents.test.ts index 5867f48f3..a7b958bdb 100644 --- a/tests/gentle-agents.test.ts +++ b/tests/gentle-agents.test.ts @@ -1907,7 +1907,7 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c const { ctx } = fakeContext(); (ctx.sessionManager as unknown as { getCwd(): string }).getCwd = () => sessionCwd; await h.fire("session_start", ctx); - shutdown.push(() => h.fire("session_shutdown", ctx)); + shutdown.push(async () => { await h.fire("session_shutdown", ctx); }); return { h, ctx, result: h.tools.get("subagent_run")!.execute(`spawn-${mode}`, { agent: "explore", task: `Capture ${mode}`, mode }, undefined, undefined, ctx) }; }; const task = await launch("task", { PATH: "/bin", FIXTURE: "task" });