From c8baad01ba18ad056a86371e8bdee9ef91d518ef Mon Sep 17 00:00:00 2001 From: ework-agent Date: Tue, 1 Sep 2026 13:25:59 +0800 Subject: [PATCH] fix: growth scale-flip reset, per-location auto-update, panel denominator Issue #267: three related fixes. 1. Growth baseline mixed two scales (main). tokenCount runs on the estimate scale while the usage anchor is stale and on the provider scale when it is fresh. A growth delta spanning that switch was a false artifact (e.g. 48k provider - 12.5k estimate ~= 35k "growth" right after a compress). The context transform now tracks the per-session anchor scale and re-anchors the growth baseline (lastNudgeShownTokens + lastPerMessageNudgeTokens) on a flip, so growth only accumulates same-source deltas. The 0.75/0.95 usage bands keep the floor-stale behavior untouched. 2. Auto-updater looped on a read-only global install and failed invisibly. The check throttle file was shared across every installed copy, so a healthy copy refreshed it and silently suppressed a failing copy's checks; the failing copy then retried the EACCES install forever with no user-visible notice. The throttle and a new stop-retry marker are now keyed per install location, a permission failure (EACCES/EPERM) marks the location read-only and stops retrying, and a one-time hint tells the user to run `npm i -g`. 3. Panel percentages used the full window as the denominator while the nudge bands run against (window - output headroom). /acp and acp_status now apply the same output-headroom reservation as the live context transform (shared applyOutputHeadroom helper) so every percentage is measured against the same real request limit. Co-Authored-By: ework daemon Rebased onto v0.1.58 (kernel 0.0.55): - keep master update.ts newer specUpdateTag/isVersionNewer/dist-tag tracking and the #289 view-recount structure; port per-location throttle/read-only logic on top - extend scale-flip reset to lastShownByTier - kernel 0.0.55 per-tier cadence baselines; old-scale lastShown minus new-scale tokenCount re-creates the false +35k growth cadence bypass from #267 --- CONFIGURATION.md | 2 + CONFIGURATION.zh-CN.md | 2 + src/commands.ts | 6 +- src/index.ts | 44 +++++++---- src/overflow-selfheal.ts | 21 +++++ src/runtime.ts | 24 +++++- src/status-tool.ts | 5 +- src/update.ts | 103 +++++++++++++++++++++--- tests/growth-scale-flip.test.ts | 135 ++++++++++++++++++++++++++++++++ tests/update.test.ts | 60 +++++++++++++- 10 files changed, 369 insertions(+), 33 deletions(-) create mode 100644 tests/growth-scale-flip.test.ts diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 5c30a20..44ade3a 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -188,6 +188,8 @@ All keys below are currently **ACTIVE**. - **Default:** `true` - **Status:** 🟢 ACTIVE - **Description:** On Pi startup, check the npm registry for a newer version of `billion-context-pi` and auto-install it. Set to `false` to avoid all startup network calls. Can also be disabled via the `ACP_AUTO_UPDATE` environment variable (`ACP_AUTO_UPDATE=0` or `ACP_AUTO_UPDATE=false`), which overrides this setting. + - **Read-only install location:** when the copy's install prefix is not writable (e.g. a root-owned `npm i -g` global prefix), auto-update stops retrying that location after the first `EACCES`/permission failure and shows a one-time hint to run `npm i -g billion-context-pi` (or remove the global copy if you rely on pi's bundled install) instead of looping. The check throttle and the stop-retry marker are keyed per install location, so a healthy copy never suppresses a failing one's checks. + - **Two parallel mechanisms:** this extension-side auto-update is independent of pi's own core update banner — both can appear, and disabling one does not disable the other. ### `modelContextLimit` diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md index 32e15d3..412ddbf 100644 --- a/CONFIGURATION.zh-CN.md +++ b/CONFIGURATION.zh-CN.md @@ -180,6 +180,8 @@ - **默认值:** `true` - **状态:** 🟢 ACTIVE - **说明:** Pi 启动时检查 npm 是否有更新版本的 `billion-context-pi` 并自动安装。设为 `false` 可避免启动时的所有网络请求。也可通过 `ACP_AUTO_UPDATE` 环境变量(`ACP_AUTO_UPDATE=0` 或 `ACP_AUTO_UPDATE=false`)禁用,该变量优先于此配置。 + - **只读安装位置:** 当该副本的安装前缀不可写(如 root 所有的 `npm i -g` 全局前缀)时,自动更新在首次 `EACCES`/权限失败后停止对该位置的重试,并一次性提示运行 `npm i -g billion-context-pi`(或若依赖 pi 自带安装则移除全局副本),而不是无限循环。检查节流与停止重试标记均按安装位置区分,因此健康副本不会压制失败副本的检查。 + - **两套并行机制:** 此扩展侧自动更新与 pi 核心自身的更新 banner 相互独立——两者可能同时出现,禁用其中一个不影响另一个。 ### `modelContextLimit` diff --git a/src/commands.ts b/src/commands.ts index 08b528d..09edfcd 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -5,6 +5,7 @@ import { defaultCountTokens, parseBlockIdArg, collectBlockContent } from "acp-ke import { getSystemPromptText } from "./compat.js"; import { collectCoveredMessageIds, estimateTokens, collectImageTokens, modelSupportsImages, adjustedTokenCount } from "./tokens.js"; import { usageAnchorPredatesCompression } from "./floor-stale.js"; +import { applyOutputHeadroom } from "./overflow-selfheal.js"; import { buildStatusPanel } from "acp-kernel/panel"; import { getDelegateUsage } from "./delegate-tool.js"; import { openFleetInspector } from "./fleet-inspector.js"; @@ -142,7 +143,10 @@ export function makeCommands(runtime: AcpRuntime, pi?: ExtensionAPI): Array<{ na async function statusReport(runtime: AcpRuntime, ctx: ExtensionCommandContext): Promise { const { state, coreMessages, entries } = await runtime.stateFor(ctx); - const config = runtime.configFor(ctx); + // Measure every panel percentage against the SAME real request limit the live + // context transform uses (window − output headroom), not the full window + // (issue #267). + const config = applyOutputHeadroom(runtime.configFor(ctx), ctx.model); // Use pi's real context usage (anchored on provider usage) only for the // panel's footer-scale display line; see sentTokens below for arbitration. const realUsage = ctx.getContextUsage?.(); diff --git a/src/index.ts b/src/index.ts index e250387..6f6680c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,7 +38,7 @@ import { } from "./throttle-retry.js"; import { defaultCountTokens } from "acp-kernel"; import { formatSystemPromptForEvent, getSystemPromptText } from "./compat.js"; -import { inspectOverflowMessage, reserveOutputHeadroom, shouldReserveOutputHeadroom } from "./overflow-selfheal.js"; +import { applyOutputHeadroom, inspectOverflowMessage } from "./overflow-selfheal.js"; import { isOmpHost, OMP_UNSUPPORTED_MESSAGE } from "./omp.js"; import { isBiliProxyBaseUrl, PROXY_STAND_DOWN_MESSAGE } from "./proxy-detect.js"; @@ -232,6 +232,7 @@ function wireSessionLifecycle(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf }); pi.on("session_shutdown", (_event, ctx) => { runtime.clearDeadCompress(ctx.sessionManager.getSessionId()); + runtime.dropTokenScale(ctx.sessionManager.getSessionId()); delegateStatusWidget.dispose(); closeLogStream(); }); @@ -273,20 +274,13 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf // Output headroom: reserve the model's max output budget from the window // so the kernel's nudge/truncate bands sit below (window - maxTokens) — // the context then always leaves room for the model's reply, preventing - // the "context + output > window" overflow on a small window. maxTokens is - // the model's max output capability (ctx.model.maxTokens). Applied to the - // (possibly re-centered) window above; never mutates the shared config. - // Anthropic is exempt — its input limit is enforced independently of - // max_tokens, so reserving would shift every band down by maxTokens with - // no safety gain (see shouldReserveOutputHeadroom). - const maxOutput = (ctx.model as { maxTokens?: number } | undefined)?.maxTokens ?? 0; - if (shouldReserveOutputHeadroom((ctx.model as { api?: string } | undefined)?.api)) { - const reservedWindow = reserveOutputHeadroom(config.modelContextLimit, maxOutput); - if (reservedWindow !== config.modelContextLimit) { - const before = config.modelContextLimit; - config = { ...config, modelContextLimit: reservedWindow }; - logInfo("overflow-selfheal", { sid, event: "output-headroom", before, after: reservedWindow, maxOutput }); - } + // the "context + output > window" overflow on a small window. Applied to + // the (possibly re-centered) window above; never mutates the shared + // config. Anthropic is exempt (see applyOutputHeadroom). + const beforeHeadroom = config.modelContextLimit; + config = applyOutputHeadroom(config, ctx.model); + if (config.modelContextLimit !== beforeHeadroom) { + logInfo("overflow-selfheal", { sid, event: "output-headroom", before: beforeHeadroom, after: config.modelContextLimit, maxOutput: (ctx.model as { maxTokens?: number } | undefined)?.maxTokens ?? 0 }); } const coveredIds = collectCoveredMessageIds(state); // Nudge arbitration on the SENT-VIEW scale: CJK-aware estimate over the @@ -329,6 +323,26 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf logInfo("turn", { sid, event: "view-recount", prelim: sentTokens, viewTokens: view.viewTokens, tokenCount }); } } + // Growth scale guard (issue #267): the meter switches rulers when the + // anchor flips stale↔not-stale (estimate ↔ provider). A growth delta + // spanning that switch is a false artifact, not real growth, so reset the + // growth baselines on the flip: the T1 growth reference (lastNudgeShownTokens + // / lastPerMessageNudgeTokens) AND the per-tier cadence baselines + // (lastShownByTier, kernel 0.0.55: cadence = tokenCount - lastShownByTier[t] + // >= growthFloor) — an old-scale lastShown subtracted from a new-scale + // tokenCount is exactly the false "+35k growth" artifact from the issue. + // The extension-side re-inject stamps (#269 / PR #316) are reset too: + // growth for the same-turn re-inject is tokenCount - nudgeShownTokensFor(turnKey) + // and an old-scale stamp would fake a full-floor growth after the flip. + // The usage bands above keep the floor-stale behavior untouched — only + // the growth references are re-anchored. + if (runtime.noteTokenScale(sid, !hostFloorActive)) { + state.nudge.lastNudgeShownTokens = 0; + state.nudge.lastPerMessageNudgeTokens = 0; + state.nudge.lastShownByTier = {}; + runtime.clearNudgeTokenStamps(); + logInfo("growth-scale", { sid, event: "scale-flip-reset", anchorStale: !hostFloorActive }); + } debug.event("context-in", { sid, modelId, diff --git a/src/overflow-selfheal.ts b/src/overflow-selfheal.ts index 72f362b..bb749b0 100644 --- a/src/overflow-selfheal.ts +++ b/src/overflow-selfheal.ts @@ -111,6 +111,27 @@ export function shouldReserveOutputHeadroom(api: string | undefined): boolean { return api !== "anthropic-messages"; } +/** + * Apply the output-headroom reservation to a resolved config's modelContextLimit + * (see reserveOutputHeadroom / shouldReserveOutputHeadroom). Returns a NEW config + * (never mutates the input) so the shared resolved config stays untouched. Used + * by BOTH the live context transform and the read-only panel surfaces (/acp, + * acp_status) so every percentage is measured against the SAME real request + * limit — otherwise the panel reports against the full window while the nudge + * bands run against (window − maxOutput) (issue #267). + */ +export function applyOutputHeadroom( + config: T, + model: { maxTokens?: number; api?: string } | undefined, +): T { + const maxOutput = model?.maxTokens ?? 0; + if (shouldReserveOutputHeadroom(model?.api)) { + const reserved = reserveOutputHeadroom(config.modelContextLimit, maxOutput); + if (reserved !== config.modelContextLimit) return { ...config, modelContextLimit: reserved }; + } + return config; +} + // Per-session overflow self-heal state. Keyed by session id so concurrent // sessions in one extension instance cannot share a learned window or an // armed emergency (same rationale as the throttle episode). diff --git a/src/runtime.ts b/src/runtime.ts index e6a5071..cfb7bbd 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -59,6 +59,13 @@ export interface AcpRuntime { * pending kick sleep and releases the map entry so a long-lived process * that cycles through many sessions doesn't accumulate them. */ throttleDrop: (sid: string) => void; + /** Per-session tokenCount scale tracker (estimate vs provider). Returns true + * when the scale just flipped (stale↔not-stale) so the caller can reset the + * growth baseline — a cross-scale delta is a false artifact, not real growth + * (issue #267). The first observation for a session never reports a flip. */ + noteTokenScale: (sid: string, stale: boolean) => boolean; + /** Drop a session's token-scale tracker (session_shutdown). */ + dropTokenScale: (sid: string) => void; store: SessionStateStore; adapter: AdapterConfig; setAdapter(adapter: AdapterConfig): void; @@ -68,6 +75,8 @@ export interface AcpRuntime { nudgeShownFor(turnKey: string): boolean; /** tokenCount at the last actual nudge injection for this turn, for growth-aware re-inject (issue #269). */ nudgeShownTokensFor(turnKey: string): number | undefined; + /** Clears the token-count stamps recorded by markNudgeShown — used on a token-scale flip (issue #267) so the same-turn re-inject floor (#269 / PR #316) is not computed against an old-scale stamp. */ + clearNudgeTokenStamps(): void; /** Process compress toolResults for the CURRENT user turn only (the caller * scopes the list — see collectCompressOutcomes in src/index.ts); idempotent * per toolCallId. Outcome classes: isError or noop (0-block panel) → @@ -295,6 +304,19 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime { throttleEpisodes.delete(sid); } + // Per-session tokenCount scale (estimate vs provider). When the anchor flips + // stale↔not-stale the meter switches rulers; a growth delta spanning that + // switch is a false artifact (issue #267), so the caller resets the baseline. + const tokenScaleStale = new Map(); + function noteTokenScale(sid: string, stale: boolean): boolean { + const prev = tokenScaleStale.get(sid); + tokenScaleStale.set(sid, stale); + return prev !== undefined && prev !== stale; + } + function dropTokenScale(sid: string): void { + tokenScaleStale.delete(sid); + } + // Compress-failure tracking (see wireContextTransform): counts FAILED/no-op // compress calls per user turn so the nudge circuit breaker can stop // re-injecting the nudge at a model that answers every nudge with another @@ -435,4 +457,4 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime { let refused = false; let refusalMessage: string | null = null; - return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get refusalMessage() { return refusalMessage; }, set refusalMessage(v: string | null) { refusalMessage = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k, t) => { nudgeShownTurns.add(k); if (t !== undefined) nudgeShownTokens.set(k, t); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), nudgeShownTokensFor: (k) => nudgeShownTokens.get(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); nudgeShownTokens.clear(); }, noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop };} + return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get refusalMessage() { return refusalMessage; }, set refusalMessage(v: string | null) { refusalMessage = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k, t) => { nudgeShownTurns.add(k); if (t !== undefined) nudgeShownTokens.set(k, t); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), nudgeShownTokensFor: (k) => nudgeShownTokens.get(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); nudgeShownTokens.clear(); }, clearNudgeTokenStamps: () => nudgeShownTokens.clear(), noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop , noteTokenScale, dropTokenScale };} diff --git a/src/status-tool.ts b/src/status-tool.ts index a248528..1fc30ad 100644 --- a/src/status-tool.ts +++ b/src/status-tool.ts @@ -4,6 +4,7 @@ import type { AcpRuntime } from "./runtime.js"; import { buildStatusReport, defaultCountTokens, formatRanges, viableRanges } from "acp-kernel"; import { estimateTokens, collectCoveredMessageIds, collectImageTokens, modelSupportsImages, adjustedTokenCount } from "./tokens.js"; import { usageAnchorPredatesCompression } from "./floor-stale.js"; +import { applyOutputHeadroom } from "./overflow-selfheal.js"; import { getSystemPromptText } from "./compat.js"; import { logThrow } from "./log.js"; import { getDelegateUsage } from "./delegate-tool.js"; @@ -49,7 +50,9 @@ export function makeStatusTool(runtime: AcpRuntime): ToolDefinition { const { state, coreMessages, entries } = await runtime.stateFor(ctx); - const config = runtime.configFor(ctx); + // Same real request limit (window − output headroom) as the live context + // transform, so the reported percentages match the nudge bands (issue #267). + const config = applyOutputHeadroom(runtime.configFor(ctx), ctx.model); // Run the same pipeline (assign-refs → prune → hide-compress-calls → ...) that // the context transform runs, so what acp_status reports matches what the // model actually receives. Without this, consumed/hidden compress calls and diff --git a/src/update.ts b/src/update.ts index 090f33e..0dc67c7 100644 --- a/src/update.ts +++ b/src/update.ts @@ -3,6 +3,7 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { execFile } from "node:child_process"; import { homedir } from "node:os"; +import { createHash } from "node:crypto"; import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent"; import { debug, logInfo, logWarn } from "./log.js"; @@ -13,12 +14,31 @@ const registryUrl = (tag: string) => `https://registry.npmjs.org/${PACKAGE_NAME}/${encodeURIComponent(tag)}`; const SEMVER_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z-.]+)?$/; const CHECK_INTERVAL_MS = 3 * 60 * 1000; +// Short stable key for an install location, used to scope the throttle + +// read-only marker files per copy. Two copies of the extension (e.g. an +// `npm i -g` global install and pi's own npm dir) must not share these files: +// a healthy copy refreshing the throttle would otherwise silently suppress a +// failing copy's checks, and a read-only location's stop-retry marker would +// wrongly apply to a writable one (issue #267). +function locationKey(location: string): string { + return createHash("sha256").update(location).digest("hex").slice(0, 12); +} // Resolved lazily (not at module load) so tests can redirect it via env at any // time. Without this, parallel test processes race on the real file under the // user's home dir: one process stamps the throttle timestamp while another has // just deleted it, making the victim's check skip "npm view" entirely. -const throttleFile = () => - process.env.ACP_UPDATE_THROTTLE_FILE ?? join(homedir(), CONFIG_DIR_NAME, "agent", ".billion-context-pi-update-check"); +// Keyed by the extension dir so each installed copy throttles independently. +const throttleFileFor = (extDir?: string): string => { + if (process.env.ACP_UPDATE_THROTTLE_FILE) return process.env.ACP_UPDATE_THROTTLE_FILE; + const base = join(homedir(), CONFIG_DIR_NAME, "agent"); + return extDir ? join(base, `.billion-context-pi-update-check-${locationKey(extDir)}`) : join(base, ".billion-context-pi-update-check"); +}; +// Persistent marker for an install location that failed with a permission +// error (EACCES/EPERM). Once set, auto-update stops retrying that location — +// a read-only global prefix can never be fixed by re-running npm install, so +// retrying is a pure infinite loop (issue #267). The user must `npm i -g`. +export const readOnlyMarkerFile = (extDir: string): string => + join(homedir(), CONFIG_DIR_NAME, "agent", `.billion-context-pi-readonly-${locationKey(extDir)}`); // Guards against concurrent checks: the context event fires on every LLM call, // so several can race past the throttle read before any writes the timestamp. @@ -176,24 +196,42 @@ function parseSemVer(version: string): { parts: number[]; pre: string[] } | unde }; } -async function readLastCheck(): Promise { +async function readLastCheck(throttle: string): Promise { try { - const data = await readFile(throttleFile(), "utf-8"); + const data = await readFile(throttle, "utf-8"); return parseInt(data.trim(), 10) || 0; } catch { return 0; } } -async function writeLastCheck(timestamp: number): Promise { +async function writeLastCheck(timestamp: number, throttle: string): Promise { try { - await mkdir(dirname(throttleFile()), { recursive: true }); - await writeFile(throttleFile(), String(timestamp), "utf-8"); + await mkdir(dirname(throttle), { recursive: true }); + await writeFile(throttle, String(timestamp), "utf-8"); } catch { // best-effort } } +async function isReadOnlyLocation(extDir: string): Promise { + try { + await access(readOnlyMarkerFile(extDir)); + return true; + } catch { + return false; + } +} + +async function markReadOnlyLocation(extDir: string): Promise { + try { + await mkdir(dirname(readOnlyMarkerFile(extDir)), { recursive: true }); + await writeFile(readOnlyMarkerFile(extDir), String(Date.now()), "utf-8"); + } catch { + // best-effort: if we can't write the marker we'll keep retrying (old behavior) + } +} + type PackageJson = { name?: string; version?: string; @@ -222,7 +260,7 @@ export function findNpmRoot(extDir: string): string | undefined { } } -async function findExtensionDir(): Promise { +export async function findExtensionDir(): Promise { let dir = dirname(fileURLToPath(import.meta.url)); for (;;) { const pkg = await readPackageJson(join(dir, "package.json")); @@ -233,7 +271,13 @@ async function findExtensionDir(): Promise { } } -export type InstallOutcome = "ok" | "failed" | "rolled-back"; +export type InstallOutcome = "ok" | "failed" | "rolled-back" | "read-only"; + +// A permission failure means the install prefix itself is not writable (e.g. an +// `npm i -g` global prefix owned by root). Re-running npm install can never fix +// that, so the caller stops retrying the location and tells the user to run +// `npm i -g` (issue #267). Matched against npm's stderr, not the exit code. +const PERMISSION_ERROR_RE = /EACCES|EPERM|permission denied|operation not permitted|read-only file system|read only file system/i; // The declared entries pi/loaders may touch: pi's own extension entry, the // ESM export, and main. All of them must exist on disk after an install. @@ -321,6 +365,13 @@ export async function autoInstallLatest(latest: string, extDirOverride?: string) npmDir, stderr: stderr.trim().slice(-2000), }); + if (PERMISSION_ERROR_RE.test(stderr)) { + // The install prefix is not writable (e.g. root-owned global prefix). + // Mark it so we stop retrying, and surface a user-visible hint. + await markReadOnlyLocation(extDir); + logWarn("update", { event: "auto-install-read-only", latest, npmDir }); + return "read-only"; + } return "failed"; } const verify = await verifyInstall(npmDir, latest); @@ -405,11 +456,20 @@ export async function checkForUpdate( if (updateInFlight) return; updateInFlight = true; try { + const extDir = await findExtensionDir(); + // A location already marked read-only (EACCES) can never be fixed by + // re-running npm install — skip the whole check (no npm view, no install) + // and remind the user once per process (issue #267). + if (extDir && (await isReadOnlyLocation(extDir))) { + notifyReadOnly(notify); + return; + } + const throttle = throttleFileFor(extDir); const now = Date.now(); - const lastCheck = await readLastCheck(); + const lastCheck = await readLastCheck(throttle); if (now - lastCheck < CHECK_INTERVAL_MS) return; - await writeLastCheck(now); + await writeLastCheck(now, throttle); const runtimeVersion = await getRuntimeVersion(); // Follow the channel the user installed from (dist-tag), not the global @@ -437,8 +497,10 @@ export async function checkForUpdate( logInfo("update", { event: "check", current, latest, hasUpdate }); if (hasUpdate) { - const outcome = await autoInstallLatest(latest); - if (outcome === "ok" && notify) { + const outcome = await autoInstallLatest(latest, extDir); + if (outcome === "read-only" && notify) { + notifyReadOnly(notify); + } else if (outcome === "ok" && notify) { notify( `\x1b[32m\u2714 ACP auto-updated ${current} \u2192 ${latest}. Restart Pi to finish.\x1b[0m`, ); @@ -463,6 +525,21 @@ export async function checkForUpdate( } } +// Emitted at most once per process so a read-only location (checked on every +// context event) does not spam a toast on each LLM call. +let readOnlyNotified = false; +function notifyReadOnly(notify?: (msg: string) => void): void { + if (!notify || readOnlyNotified) return; + readOnlyNotified = true; + notify( + `\x1b[33m\u26a0 ACP auto-update cannot write to this install location (no permission). ` + + `To update run \`npm i -g ${PACKAGE_NAME}\`, or remove the global copy if you rely on pi's bundled install.\x1b[0m`, + ); +} +export function resetUpdateStateForTest(): void { + readOnlyNotified = false; +} + async function getRuntimeVersion(): Promise { const extDir = await findExtensionDir(); if (!extDir) return undefined; diff --git a/tests/growth-scale-flip.test.ts b/tests/growth-scale-flip.test.ts new file mode 100644 index 0000000..cf405a1 --- /dev/null +++ b/tests/growth-scale-flip.test.ts @@ -0,0 +1,135 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile, rm } from "node:fs/promises"; +import { createAcpExtension } from "../src/index.js"; + +// issue #267: the meter switches rulers when the usage anchor flips +// stale↔not-stale (estimate ↔ provider). A growth delta spanning that switch +// is a false artifact. The context transform must re-anchor the growth +// baseline on the flip so growth only accumulates same-source deltas — while +// the usage bands keep the floor-stale behavior untouched. + +const STATE_FILE = "/tmp/pai-acp-growth-scale.session.json"; + +function captureApi() { + const handlers = new Map any)[]>(); + const api = { + on(event: string, handler: (e: any, ctx: any) => any) { + const list = handlers.get(event) ?? []; + list.push(handler); + handlers.set(event, list); + }, + tools: [] as any[], + commands: new Map(), + registerTool(tool: any) { this.tools.push(tool); }, + registerCommand(name: string, options: any) { this.commands.set(name, options); }, + }; + return { api, handlers }; +} + +function msg(id: string, role: string, text: string, over: Record = {}) { + return { type: "message", id, parentId: null, timestamp: "", message: { role, content: text, timestamp: Date.now(), ...over } }; +} + +const MID = "lorem ".repeat(3000); +const COMPRESS_PANEL = "▣ ACP | 42.3K → 18.9K tokens (~23.4K reclaimed, 3 blocks)"; + +let branchEntries: any[] = []; + +function fakeCtx(tokens: number) { + return { + mode: "rpc" as const, + hasUI: false, + ui: { notify: () => {}, confirm: async () => true, select: async () => undefined, input: async () => "", setStatus: () => {} }, + model: { contextWindow: 180_000 }, + sessionManager: { + getBranch: () => branchEntries as any[], + getSessionId: () => "growth-scale", + getSessionFile: () => STATE_FILE, + }, + getContextUsage: () => ({ tokens, percent: tokens / 180_000, contextWindow: 180_000 }), + }; +} + +const fire = (handlers: Map any)[]>, entries: any[], ctx: any) => + handlers.get("context")![0]!({ type: "context", messages: entries.map((e) => e.message) }, ctx); + +// 20 MID-sized messages (~75-90K estimate) + a 175K provider usage anchor. +function bulkEntries(): any[] { + const entries: any[] = [msg("e0", "user", "start " + MID)]; + for (let i = 1; i <= 18; i++) entries.push(msg(`e${i}`, i % 2 ? "assistant" : "user", `f${i} ` + MID)); + return entries; +} + +test("growth baseline re-anchors on the stale→not-stale scale flip (no cross-scale false growth)", async () => { + await rm(`${STATE_FILE}.acp.json`, { force: true }); + const { api, handlers } = captureApi(); + createAcpExtension({ modelContextLimit: 180_000 })(api as any); + + // Turn 1 — STALE: the successful compress lands after the 175K usage anchor, + // so the meter runs on the estimate scale (sentTokens), far below 175K. + const staleEntries = [ + ...bulkEntries(), + { type: "message", id: "e19", parentId: null, timestamp: "", message: { role: "assistant", content: "f19 " + MID, timestamp: Date.now(), usage: { input: 175_000, cacheRead: 0, cacheWrite: 0 } } }, + { type: "message", id: "e20", parentId: null, timestamp: "", message: { role: "toolResult", toolName: "compress", toolCallId: "c1", content: [{ type: "text", text: COMPRESS_PANEL }], timestamp: Date.now() } }, + ]; + branchEntries = staleEntries; + await fire(handlers, staleEntries, fakeCtx(175_000)); + const afterStale = JSON.parse(await readFile(`${STATE_FILE}.acp.json`, "utf-8")); + assert.ok(afterStale.nudge.lastPerMessageNudgeTokens < 100_000, "turn 1 baseline on estimate scale"); + + // Turn 2 — NOT-STALE: a fresh 175K usage lands after the compress, so the + // meter switches to the provider scale. Without the scale-flip reset the + // baseline would still be the turn-1 estimate and growth would read as a + // huge false delta (provider − estimate). + const freshEntries = [ + ...staleEntries, + { type: "message", id: "e21", parentId: null, timestamp: "", message: { role: "assistant", content: "f20 " + MID, timestamp: Date.now(), usage: { input: 175_000, cacheRead: 0, cacheWrite: 0 } } }, + ]; + branchEntries = freshEntries; + await fire(handlers, freshEntries, fakeCtx(175_000)); + const afterFresh = JSON.parse(await readFile(`${STATE_FILE}.acp.json`, "utf-8")); + assert.ok( + afterFresh.nudge.lastPerMessageNudgeTokens >= 170_000, + `baseline re-anchored to provider scale after flip (got ${afterFresh.nudge.lastPerMessageNudgeTokens})`, + ); + // Per-tier cadence baselines (kernel 0.0.55 lastShownByTier) must also live + // on one scale: old-scale lastShown minus new-scale tokenCount is exactly + // the false "+35k growth" cadence bypass from issue #267. + for (const [tier, shownAt] of Object.entries(afterFresh.nudge.lastShownByTier ?? {})) { + assert.ok( + (shownAt as number) >= 170_000, + `tier ${tier} cadence baseline re-anchored after flip (got ${shownAt})`, + ); + } + await rm(`${STATE_FILE}.acp.json`, { force: true }); +}); + +test("no reset when the scale is stable (baseline keeps accumulating same-source growth)", async () => { + await rm(`${STATE_FILE}.acp.json`, { force: true }); + const { api, handlers } = captureApi(); + createAcpExtension({ modelContextLimit: 180_000 })(api as any); + + // Two consecutive NOT-STALE turns (provider scale, no compress in between): + // the baseline must NOT be reset — it stays anchored so real growth is tracked. + const entriesA = [ + ...bulkEntries(), + { type: "message", id: "e19", parentId: null, timestamp: "", message: { role: "assistant", content: "f19 " + MID, timestamp: Date.now(), usage: { input: 120_000, cacheRead: 0, cacheWrite: 0 } } }, + ]; + branchEntries = entriesA; + await fire(handlers, entriesA, fakeCtx(120_000)); + const afterA = JSON.parse(await readFile(`${STATE_FILE}.acp.json`, "utf-8")); + const baselineA = afterA.nudge.lastPerMessageNudgeTokens; + assert.ok(baselineA >= 115_000, `turn 1 baseline on provider scale (got ${baselineA})`); + + const entriesB = [ + ...entriesA, + { type: "message", id: "e20", parentId: null, timestamp: "", message: { role: "assistant", content: "f20 " + MID, timestamp: Date.now(), usage: { input: 130_000, cacheRead: 0, cacheWrite: 0 } } }, + ]; + branchEntries = entriesB; + await fire(handlers, entriesB, fakeCtx(130_000)); + const afterB = JSON.parse(await readFile(`${STATE_FILE}.acp.json`, "utf-8")); + // Same scale (provider) both turns → no flip → baseline unchanged from turn 1. + assert.equal(afterB.nudge.lastPerMessageNudgeTokens, baselineA, "stable scale keeps the baseline (no spurious reset)"); + await rm(`${STATE_FILE}.acp.json`, { force: true }); +}); diff --git a/tests/update.test.ts b/tests/update.test.ts index 6a5ca8c..c7dd614 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -1,8 +1,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { join } from "node:path"; +import { join, dirname } from "node:path"; import { homedir, tmpdir } from "node:os"; -import { mkdtempSync, readFileSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, mkdirSync, writeFileSync, existsSync } from "node:fs"; import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent"; import type { NpmRunner } from "../src/update.js"; @@ -35,6 +35,9 @@ const { isAutoUpdatableSpec, runNpm, runNode, + findExtensionDir, + readOnlyMarkerFile, + resetUpdateStateForTest, } = await import("../src/update.js"); const THROTTLE = join( @@ -447,3 +450,56 @@ test("autoInstallLatest: npm install failure → failed, no rollback, no verify" assert.equal(nodeCalls, 0); rmSync(fx.root, { recursive: true, force: true }); }); + +// --- read-only (EACCES) handling (issue #267) --- + +test("autoInstallLatest: EACCES install failure → read-only outcome + stop-retry marker written", async () => { + const fx = makeFixture(); + fx.writeInstalled("1.2.3"); + setRunNpmForTest(makeFakeNpm( + { code: 0, stdout: "", stderr: "" }, + { code: 1, stdout: "", stderr: "npm error code EACCES\nnpm error syscall open\nnpm error errno -13" }, + ).impl); + setRunNodeForTest(async () => ({ code: 0, stdout: "", stderr: "" })); + const outcome = await autoInstallLatest("9.9.9", fx.extDir); + assert.equal(outcome, "read-only"); + assert.ok(existsSync(readOnlyMarkerFile(fx.extDir)), "stop-retry marker written for the read-only location"); + rmSync(fx.root, { recursive: true, force: true }); +}); + +test("autoInstallLatest: non-permission failure (404) → failed, no stop-retry marker", async () => { + const fx = makeFixture(); + fx.writeInstalled("1.2.3"); + setRunNpmForTest(makeFakeNpm( + { code: 0, stdout: "", stderr: "" }, + { code: 1, stdout: "", stderr: "npm error 404 Not Found - GET" }, + ).impl); + setRunNodeForTest(async () => ({ code: 0, stdout: "", stderr: "" })); + const outcome = await autoInstallLatest("9.9.9", fx.extDir); + assert.equal(outcome, "failed"); + assert.ok(!existsSync(readOnlyMarkerFile(fx.extDir)), "no marker for a non-permission failure"); + rmSync(fx.root, { recursive: true, force: true }); +}); + +test("checkForUpdate: read-only marker present → skips npm view entirely + notifies once per process", async () => { + resetUpdateStateForTest(); + const extDir = await findExtensionDir(); + assert.ok(extDir, "extension dir resolvable in test"); + mkdirSync(dirname(readOnlyMarkerFile(extDir)), { recursive: true }); + writeFileSync(readOnlyMarkerFile(extDir), String(Date.now())); + let npmCalls = 0; + setRunNpmForTest(async () => { npmCalls += 1; return { code: 0, stdout: "", stderr: "" }; }); + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { throw new Error("fetch must not be called for a read-only location"); }) as typeof fetch; + const notes: string[] = []; + try { + await checkForUpdate(true, (m) => notes.push(m)); + await checkForUpdate(true, (m) => notes.push(m)); + } finally { + globalThis.fetch = originalFetch; + rmSync(readOnlyMarkerFile(extDir), { force: true }); + } + assert.equal(npmCalls, 0, "no npm view when the install location is read-only"); + assert.equal(notes.length, 1, "notify emitted once per process, not once per check"); + assert.match(notes[0], /npm i -g billion-context-pi/); +});