Phase 14: B-002 pre-fix RPC repro — filter snapshots and readSettings fix
RPC E2E with debug shows registered present at session_compact but planned=[] because kept still contains the skill block; registered=[] still drops skills absent from kept. Sync file readSettings avoids RPC hook deadlock on SettingsManager/isProjectTrusted. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,22 @@
|
|||||||
|
# B-002 pre-fix RPC repro (Phase 14)
|
||||||
|
|
||||||
|
Run: `node scripts/b002-repro-pre-fix.mjs` (requires LiteLLM model `Eltex-Coder-Senior`, skill at `~/.cursor/skills/fup-blame-commits`).
|
||||||
|
|
||||||
|
Patches `~/.pi/agent/settings.json` temporarily (`skillReinject.debug: true`) and restores on exit.
|
||||||
|
|
||||||
|
## 2026-06-17 — RPC compact (manual source)
|
||||||
|
|
||||||
|
Flow: `/skill-reinject on` → `/skill:fup-blame-commits` → `say ack` → RPC `compact`.
|
||||||
|
|
||||||
|
| Phase | tracked | kept | registered | planned | pending |
|
||||||
|
|-------|---------|------|------------|---------|---------|
|
||||||
|
| `before_agent_start` (1st turn) | `[]` | `[]` | `["fup-blame-commits"]` | `[]` | `[]` |
|
||||||
|
| `session_compact` | `["fup-blame-commits"]` | `["fup-blame-commits"]` | `["fup-blame-commits"]` | `[]` | `[]` |
|
||||||
|
|
||||||
|
**Compaction source:** manual (`{type:"compact"}` RPC). `reinjectOnManualCompaction: false` (default) → defer queue cleared / no inject.
|
||||||
|
|
||||||
|
**Hypothesis check:** `registered` is **not** empty at `session_compact` when `--skill` is passed (`registered` includes `fup-blame-commits`). `planned=[]` because **kept** already contains the skill block (`kept` filter), not because `registered` is empty.
|
||||||
|
|
||||||
|
**Extra:** `readSettings()` via `SettingsManager` / `ctx.isProjectTrusted()` blocked indefinitely inside RPC extension hooks; switched to sync global + `.pi/settings.json` merge (see `settings.ts`).
|
||||||
|
|
||||||
|
**Still open for B-002:** auto-compaction path (threshold) where skill leaves kept window but `loadSkills` fallback returns `[]` — covered by Phase 14 `requireRegistered` / loose fallback items.
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Phase 14 manual repro (B-002 pre-fix): RPC flow with skillReinject.debug.
|
||||||
|
* Captures diag lines from extension stderr (console.error) and extension_ui notify.
|
||||||
|
*/
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { mkdtempSync, readFileSync, writeFileSync, rmSync, existsSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
|
||||||
|
const skillPath = resolve(process.env.B002_SKILL_PATH ?? `${process.env.HOME}/.cursor/skills/fup-blame-commits`);
|
||||||
|
const extensionPath = join(repoRoot, "src/index.ts");
|
||||||
|
const globalSettingsPath = join(process.env.HOME ?? "", ".pi/agent/settings.json");
|
||||||
|
const globalBackupPath = `${globalSettingsPath}.b002-backup`;
|
||||||
|
|
||||||
|
function patchGlobalSettings() {
|
||||||
|
if (!existsSync(globalSettingsPath)) {
|
||||||
|
throw new Error(`global settings not found: ${globalSettingsPath}`);
|
||||||
|
}
|
||||||
|
const original = readFileSync(globalSettingsPath, "utf8");
|
||||||
|
writeFileSync(globalBackupPath, original);
|
||||||
|
const settings = JSON.parse(original);
|
||||||
|
settings.skillReinject = {
|
||||||
|
...settings.skillReinject,
|
||||||
|
enabled: true,
|
||||||
|
debug: true,
|
||||||
|
autoCompactIntegration: "defer",
|
||||||
|
};
|
||||||
|
writeFileSync(globalSettingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreGlobalSettings(original) {
|
||||||
|
writeFileSync(globalSettingsPath, original);
|
||||||
|
rmSync(globalBackupPath, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionDir = mkdtempSync(join(tmpdir(), "b002-session-"));
|
||||||
|
let originalSettings;
|
||||||
|
try {
|
||||||
|
originalSettings = patchGlobalSettings();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error instanceof Error ? error.message : error);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 1;
|
||||||
|
const pending = new Map();
|
||||||
|
const diagNotifies = [];
|
||||||
|
let compactResponse;
|
||||||
|
let statusBeforeCompact;
|
||||||
|
let statusAfterCompact;
|
||||||
|
|
||||||
|
function send(child, cmd) {
|
||||||
|
const id = `req-${nextId++}`;
|
||||||
|
child.stdin.write(`${JSON.stringify({ id, ...cmd })}\n`);
|
||||||
|
return new Promise((resolvePromise, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pending.delete(id);
|
||||||
|
reject(new Error(`timeout waiting for ${cmd.type} (${id})`));
|
||||||
|
}, Number(process.env.B002_STEP_TIMEOUT_MS ?? 180_000));
|
||||||
|
pending.set(id, {
|
||||||
|
resolve: (value) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolvePromise(value);
|
||||||
|
},
|
||||||
|
reject: (error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureDiagLine(line) {
|
||||||
|
if (line.includes("skill-reinject [session_compact]:") || line.includes("skill-reinject [before_agent_start]:")) {
|
||||||
|
diagNotifies.push(line.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const piArgs = [
|
||||||
|
"--mode",
|
||||||
|
"rpc",
|
||||||
|
"-e",
|
||||||
|
extensionPath,
|
||||||
|
"--skill",
|
||||||
|
skillPath,
|
||||||
|
"--session-dir",
|
||||||
|
sessionDir,
|
||||||
|
"--model",
|
||||||
|
process.env.B002_MODEL ?? "Eltex-Coder-Senior",
|
||||||
|
"--no-session",
|
||||||
|
];
|
||||||
|
|
||||||
|
const child = spawn("pi", piArgs, {
|
||||||
|
cwd: repoRoot,
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdoutBuffer = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
stdoutBuffer += chunk.toString("utf8");
|
||||||
|
let newlineIndex = stdoutBuffer.indexOf("\n");
|
||||||
|
while (newlineIndex >= 0) {
|
||||||
|
const line = stdoutBuffer.slice(0, newlineIndex).replace(/\r$/, "");
|
||||||
|
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
|
||||||
|
if (!line.trim()) {
|
||||||
|
newlineIndex = stdoutBuffer.indexOf("\n");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const record = JSON.parse(line);
|
||||||
|
if (record.type === "extension_ui_request" && record.method === "notify") {
|
||||||
|
if (typeof record.message === "string") {
|
||||||
|
captureDiagLine(record.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (record.type === "message" && record.message?.role === "assistant") {
|
||||||
|
const text = JSON.stringify(record.message.content ?? "");
|
||||||
|
if (text.includes("skill-reinject:")) {
|
||||||
|
if (!statusBeforeCompact && text.includes("tracked:")) {
|
||||||
|
statusBeforeCompact = text;
|
||||||
|
} else if (text.includes("tracked:")) {
|
||||||
|
statusAfterCompact = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (record.type === "response" && record.id && pending.has(record.id)) {
|
||||||
|
pending.get(record.id).resolve(record);
|
||||||
|
pending.delete(record.id);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore non-JSON stdout
|
||||||
|
}
|
||||||
|
newlineIndex = stdoutBuffer.indexOf("\n");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const stderrChunks = [];
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
const text = chunk.toString("utf8");
|
||||||
|
stderrChunks.push(chunk);
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
captureDiagLine(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", () => {
|
||||||
|
for (const { reject } of pending.values()) {
|
||||||
|
reject(new Error("pi process exited"));
|
||||||
|
}
|
||||||
|
pending.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function runFlow() {
|
||||||
|
await new Promise((resolvePromise) => setTimeout(resolvePromise, 3000));
|
||||||
|
|
||||||
|
await send(child, { type: "prompt", message: "/skill-reinject on" });
|
||||||
|
await send(child, { type: "prompt", message: "/skill:fup-blame-commits" });
|
||||||
|
await send(child, { type: "prompt", message: "Reply with exactly: ack" });
|
||||||
|
await send(child, { type: "prompt", message: "/skill-reinject" });
|
||||||
|
|
||||||
|
compactResponse = await send(child, { type: "compact" });
|
||||||
|
await send(child, { type: "prompt", message: "/skill-reinject" });
|
||||||
|
await send(child, { type: "prompt", message: "Reply with exactly: after-compact" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let flowError;
|
||||||
|
let exitCode = 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
runFlow().then(() => child.stdin.end()),
|
||||||
|
new Promise((_, reject) =>
|
||||||
|
setTimeout(
|
||||||
|
() => reject(new Error(`global timeout after ${process.env.B002_TIMEOUT_MS ?? 360_000}ms`)),
|
||||||
|
Number(process.env.B002_TIMEOUT_MS ?? 360_000),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
exitCode = await new Promise((resolvePromise) => {
|
||||||
|
child.on("close", (code) => resolvePromise(code ?? 1));
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
flowError = error instanceof Error ? error.message : String(error);
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
} finally {
|
||||||
|
restoreGlobalSettings(originalSettings);
|
||||||
|
rmSync(sessionDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stderr = Buffer.concat(stderrChunks).toString("utf8");
|
||||||
|
const parsedDiag = diagNotifies.map((line) => {
|
||||||
|
const match = line.match(/skill-reinject \[(session_compact|before_agent_start)\]: (.+)$/);
|
||||||
|
if (!match) {
|
||||||
|
return { raw: line };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return { phase: match[1], snapshot: JSON.parse(match[2]) };
|
||||||
|
} catch {
|
||||||
|
return { phase: match[1], raw: match[2] };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
exitCode,
|
||||||
|
flowError,
|
||||||
|
skillPath,
|
||||||
|
compactionSource: "manual (RPC compact)",
|
||||||
|
compactResponse,
|
||||||
|
statusBeforeCompact,
|
||||||
|
statusAfterCompact,
|
||||||
|
diagNotifies,
|
||||||
|
parsedDiag,
|
||||||
|
stderrTail: stderr.trim().split("\n").filter((l) => !l.includes("Llama.cpp")).slice(-15).join("\n"),
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(JSON.stringify(summary, null, 2));
|
||||||
|
process.exit(diagNotifies.length > 0 && !flowError ? 0 : 1);
|
||||||
+6
-2
@@ -35,8 +35,12 @@ export function notifyReinjectDiag(
|
|||||||
phase: ReinjectDiagPhase,
|
phase: ReinjectDiagPhase,
|
||||||
snapshot: ReinjectDiagSnapshot,
|
snapshot: ReinjectDiagSnapshot,
|
||||||
): void {
|
): void {
|
||||||
if (!settings.debug || !ctx?.hasUI) {
|
if (!settings.debug) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ctx.ui.notify(`skill-reinject [${phase}]: ${JSON.stringify(snapshot)}`, "info");
|
const message = `skill-reinject [${phase}]: ${JSON.stringify(snapshot)}`;
|
||||||
|
console.error(message);
|
||||||
|
if (ctx?.hasUI) {
|
||||||
|
ctx.ui.notify(message, "info");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-9
@@ -1,5 +1,5 @@
|
|||||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||||
import { SettingsManager, getAgentDir } from "@earendil-works/pi-coding-agent";
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
import type { AutoCompactIntegration } from "./state";
|
import type { AutoCompactIntegration } from "./state";
|
||||||
@@ -104,15 +104,14 @@ function extractSkillReinject(settings: object): PartialSkillReinjectSettings {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Merged global + project settings via Pi SettingsManager (SPEC §7.3). */
|
/** Merged global + project settings (SPEC §7.3). Sync file read; avoids SettingsManager / isProjectTrusted() blocking in RPC hooks. */
|
||||||
export function readSettings(ctx: ExtensionContext): SkillReinjectSettings {
|
export function readSettings(ctx: ExtensionContext): SkillReinjectSettings {
|
||||||
const manager = SettingsManager.create(ctx.cwd, getAgentDir(), {
|
const global = extractSkillReinject(readSettingsFile(join(getAgentDir(), "settings.json")));
|
||||||
projectTrusted: ctx.isProjectTrusted(),
|
const projectPath = join(ctx.cwd, ".pi/settings.json");
|
||||||
});
|
const project = existsSync(projectPath)
|
||||||
return mergeSkillReinjectSettings(
|
? extractSkillReinject(readSettingsFile(projectPath))
|
||||||
extractSkillReinject(manager.getGlobalSettings()),
|
: {};
|
||||||
extractSkillReinject(manager.getProjectSettings()),
|
return mergeSkillReinjectSettings(global, project);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function readSettingsFile(settingsPath: string): Record<string, unknown> {
|
function readSettingsFile(settingsPath: string): Record<string, unknown> {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { planReinject } from "../src/reinject.js";
|
||||||
|
import { createDefaultSettings } from "../src/settings.js";
|
||||||
|
import { createInitialState, trackSkill } from "../src/state.js";
|
||||||
|
|
||||||
|
describe("B-002 pre-fix filter hypothesis", () => {
|
||||||
|
it("planned empty when skill stays in kept window even if registered", () => {
|
||||||
|
const state = createInitialState();
|
||||||
|
trackSkill(state, {
|
||||||
|
name: "fup-blame-commits",
|
||||||
|
filePath: "/home/user/.cursor/skills/fup-blame-commits/SKILL.md",
|
||||||
|
baseDir: "/home/user/.cursor/skills/fup-blame-commits",
|
||||||
|
source: "slash",
|
||||||
|
});
|
||||||
|
|
||||||
|
const branch = [
|
||||||
|
{
|
||||||
|
id: "keep-1",
|
||||||
|
type: "message",
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
content:
|
||||||
|
'<skill name="fup-blame-commits" location="/path/SKILL.md">\nbody\n</skill>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as never;
|
||||||
|
|
||||||
|
const planned = planReinject(
|
||||||
|
state,
|
||||||
|
createDefaultSettings(),
|
||||||
|
{
|
||||||
|
sessionManager: { getBranch: () => branch },
|
||||||
|
} as never,
|
||||||
|
{ compactionEntry: { firstKeptEntryId: "keep-1" } } as never,
|
||||||
|
[{ name: "fup-blame-commits" }],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(planned).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pre-fix: registered empty drops skill even when absent from kept (post-fix should reinject)", () => {
|
||||||
|
const state = createInitialState();
|
||||||
|
trackSkill(state, {
|
||||||
|
name: "fup-blame-commits",
|
||||||
|
filePath: "/home/user/.cursor/skills/fup-blame-commits/SKILL.md",
|
||||||
|
baseDir: "/home/user/.cursor/skills/fup-blame-commits",
|
||||||
|
source: "skill-block",
|
||||||
|
});
|
||||||
|
|
||||||
|
const branch = [
|
||||||
|
{
|
||||||
|
id: "keep-1",
|
||||||
|
type: "message",
|
||||||
|
message: { role: "user", content: "plain text after compact" },
|
||||||
|
},
|
||||||
|
] as never;
|
||||||
|
|
||||||
|
const planned = planReinject(
|
||||||
|
state,
|
||||||
|
createDefaultSettings(),
|
||||||
|
{
|
||||||
|
sessionManager: { getBranch: () => branch },
|
||||||
|
} as never,
|
||||||
|
{ compactionEntry: { firstKeptEntryId: "keep-1" } } as never,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(planned).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -56,6 +56,7 @@ describe("notifyReinjectDiag", () => {
|
|||||||
|
|
||||||
it("notifies with JSON snapshot when debug is on", () => {
|
it("notifies with JSON snapshot when debug is on", () => {
|
||||||
const notify = vi.fn();
|
const notify = vi.fn();
|
||||||
|
const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
const settings = { ...createDefaultSettings(), debug: true };
|
const settings = { ...createDefaultSettings(), debug: true };
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
tracked: ["a"],
|
tracked: ["a"],
|
||||||
@@ -73,9 +74,13 @@ describe("notifyReinjectDiag", () => {
|
|||||||
"before_agent_start",
|
"before_agent_start",
|
||||||
snapshot,
|
snapshot,
|
||||||
);
|
);
|
||||||
|
expect(stderrSpy).toHaveBeenCalledWith(
|
||||||
|
`skill-reinject [before_agent_start]: ${JSON.stringify(snapshot)}`,
|
||||||
|
);
|
||||||
expect(notify).toHaveBeenCalledWith(
|
expect(notify).toHaveBeenCalledWith(
|
||||||
`skill-reinject [before_agent_start]: ${JSON.stringify(snapshot)}`,
|
`skill-reinject [before_agent_start]: ${JSON.stringify(snapshot)}`,
|
||||||
"info",
|
"info",
|
||||||
);
|
);
|
||||||
|
stderrSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user