Skip to content

Commit f3cb478

Browse files
committed
fix: close 4 findings from Oracle audit wave 6 (key-files + resilience)
Two read-only Oracles (resilience spine: overflow/fallback/retry; key-files subsystem). NO P0s — the periphery is converging. Cores verified safe (no builtin fallback fan-out, retryable-only iteration, OpenCode+Pi subagent termination parity, storage/versioning/validator/atomic-commit, XML escaping, AFT detection). 4 source-confirmed fixes; design/cache-adjacent items banked (D19-D21). - Key-files child session deleted before validation (identify-key-files.ts). Completes the wave-3 retention-on-failure fix, which Oracle Q found incomplete: runKeyFilesLlm set succeeded=true on text-extraction and deleted the child session — BEFORE validateLlmOutput runs. An LLM that returned text but failed validation (bad JSON / doc / out-of-candidate path) lost its session, defeating debugging. Moved cleanup ownership to runKeyFilesTask: delete only after validation + commit succeed (taskCompleted), retain on any failure. - Stale pinned key-file persists forever (identify-key-files.ts). The no_change short-circuit only checked every candidate is pinned, NOT that every pinned row is still a valid candidate — so a file that dropped out of the candidate set (renamed / below min_reads / doc-lockfile under an older policy) kept being injected. Added the reverse-membership clause so a stale row falls through to the LLM re-run, which validates against the candidate allow-set and drops it. - abortChildRun could hang and mask the original error (model-suggestion-retry.ts). The best-effort child-session abort awaited client.session.abort() unbounded; a wedged abort endpoint would hang the caller and hide the timeout/abort error we need to surface. Race it against a 3s timer (the abort still proceeds server-side). - Documented the overflow-detection plausible-limit floor (overflow-detection.ts): evaluated raising it to the [20k,3M] trusted-limit band but kept 1024 — overflow detection honors a provider EXPLICITLY stating its limit, and real 4096/8192 local models exist (a test asserts 4096 is accepted); the band would discard a legitimate small-model signal. Comment-only. Banked: detected-limit not model-keyed + missing Anthropic ">N maximum" extractor (P-1/design), validation-aware fallback for dreamer/sidekick empty-output (P-2, touches fallback design), transform-wrapper snapshot-restore (P-6 — its fix is the full-array clone-per-pass the user rejects; needs a targeted-catch design), persistent-storage fail-open-vs-closed (P), key-files injection-cache project key + Pi project-switch m1 replay (Q — cache-adjacent). See oracle-loop-findings.md. Gate: plugin 2176/0, Pi tsc clean, tsc+biome clean.
1 parent ad54f36 commit f3cb478

3 files changed

Lines changed: 85 additions & 48 deletions

File tree

packages/plugin/src/features/magic-context/key-files/identify-key-files.ts

Lines changed: 67 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,7 @@ async function runKeyFilesLlm(args: {
464464
prompt: string;
465465
deadline: number;
466466
fallbackModels?: readonly string[];
467-
}): Promise<{ text: string; messages: unknown[] }> {
467+
}): Promise<{ text: string; messages: unknown[]; agentSessionId: string }> {
468468
const createResponse = await args.client.session.create({
469469
body: {
470470
...(args.parentSessionId ? { parentID: args.parentSessionId } : {}),
@@ -477,46 +477,39 @@ async function runKeyFilesLlm(args: {
477477
});
478478
const agentSessionId = typeof created?.id === "string" ? created.id : null;
479479
if (!agentSessionId) throw new Error("Could not create key-file identification session.");
480-
let succeeded = false;
481-
try {
482-
await shared.promptSyncWithModelSuggestionRetry(
483-
args.client,
484-
{
485-
path: { id: agentSessionId },
486-
query: { directory: args.projectPath },
487-
body: {
488-
agent: DREAMER_AGENT,
489-
system: KEY_FILES_SYSTEM_PROMPT,
490-
parts: [{ type: "text", text: args.prompt, synthetic: true }],
491-
},
492-
},
493-
{
494-
timeoutMs: Math.min(Math.max(0, args.deadline - Date.now()), 5 * 60 * 1000),
495-
fallbackModels: args.fallbackModels,
496-
callContext: "dreamer:key-files-v6",
497-
},
498-
);
499-
const messagesResponse = await args.client.session.messages({
480+
// NOTE: child-session cleanup is OWNED BY THE CALLER (runKeyFilesTask), not
481+
// here. Deleting on text-extraction success would discard the session BEFORE
482+
// validateLlmOutput runs — so an LLM that returned text but failed validation
483+
// (bad JSON, doc/out-of-candidate path) would lose its session, defeating
484+
// retention-on-failure debugging. The caller deletes only after validation +
485+
// commit succeed, and retains on any failure.
486+
await shared.promptSyncWithModelSuggestionRetry(
487+
args.client,
488+
{
500489
path: { id: agentSessionId },
501-
query: { directory: args.projectPath, limit: 50 },
502-
});
503-
const messages = shared.normalizeSDKResponse(messagesResponse, [] as unknown[], {
504-
preferResponseOnMissingData: true,
505-
});
506-
const text = extractLatestAssistantText(messages);
507-
if (!text) throw new Error("Dreamer returned no key-files output.");
508-
succeeded = true;
509-
return { text, messages };
510-
} finally {
511-
// Keep the child session on failure (debugging) — mirrors the main-task
512-
// cleanup rule; this try/finally has no catch, so a throw leaves
513-
// succeeded=false and the session is retained.
514-
if (succeeded && !shouldKeepSubagents()) {
515-
await args.client.session
516-
.delete({ path: { id: agentSessionId } })
517-
.catch(() => undefined);
518-
}
519-
}
490+
query: { directory: args.projectPath },
491+
body: {
492+
agent: DREAMER_AGENT,
493+
system: KEY_FILES_SYSTEM_PROMPT,
494+
parts: [{ type: "text", text: args.prompt, synthetic: true }],
495+
},
496+
},
497+
{
498+
timeoutMs: Math.min(Math.max(0, args.deadline - Date.now()), 5 * 60 * 1000),
499+
fallbackModels: args.fallbackModels,
500+
callContext: "dreamer:key-files-v6",
501+
},
502+
);
503+
const messagesResponse = await args.client.session.messages({
504+
path: { id: agentSessionId },
505+
query: { directory: args.projectPath, limit: 50 },
506+
});
507+
const messages = shared.normalizeSDKResponse(messagesResponse, [] as unknown[], {
508+
preferResponseOnMissingData: true,
509+
});
510+
const text = extractLatestAssistantText(messages);
511+
if (!text) throw new Error("Dreamer returned no key-files output.");
512+
return { text, messages, agentSessionId };
520513
}
521514

522515
export async function runKeyFilesTask(args: {
@@ -557,10 +550,18 @@ export async function runKeyFilesTask(args: {
557550
}
558551
});
559552
const currentPaths = new Set(currentRows.map((row) => row.path));
560-
if (
561-
allRowsFreshAndCurrent &&
562-
candidates.every((candidate) => currentPaths.has(candidate.path))
563-
) {
553+
const candidatePaths = new Set(candidates.map((candidate) => candidate.path));
554+
// Short-circuit ONLY when the pinned set exactly matches the current candidate
555+
// set: every candidate is already pinned AND every pinned row is still a valid
556+
// candidate. The second clause is the fix — without it, a pinned file that
557+
// dropped out of the candidate set (renamed, fell below min_reads, or a doc/
558+
// lockfile under an older policy) would be treated as "no change" and injected
559+
// forever. When a stale row exists we fall through to the LLM re-run, which
560+
// validates against the candidate allow-set and drops it.
561+
const pinnedMatchesCandidates =
562+
candidates.every((candidate) => currentPaths.has(candidate.path)) &&
563+
currentRows.every((row) => candidatePaths.has(row.path));
564+
if (allRowsFreshAndCurrent && pinnedMatchesCandidates) {
564565
log(`key-files: no_change short-circuit (${currentRows.length} rows fresh)`);
565566
return { committedVersion: null, candidates: candidates.length, noChange: true };
566567
}
@@ -585,6 +586,11 @@ export async function runKeyFilesTask(args: {
585586
// pushed in the dreamer runner ("key files") so the dashboard maps tokens
586587
// to the right row.
587588
let invocationRecorded = false;
589+
// Child-session cleanup is owned here (not in runKeyFilesLlm) so it happens
590+
// only AFTER validation + commit succeed — a validation failure retains the
591+
// session for debugging (retention-on-failure parity with the main task).
592+
let childSessionId: string | null = null;
593+
let taskCompleted = false;
588594
const llmStartedAt = Date.now();
589595
const recordKeyFilesInvocation = (params: {
590596
status: "completed" | "failed";
@@ -607,14 +613,19 @@ export async function runKeyFilesTask(args: {
607613
};
608614
try {
609615
try {
610-
const { text: raw, messages: llmMessages } = await runKeyFilesLlm({
616+
const {
617+
text: raw,
618+
messages: llmMessages,
619+
agentSessionId,
620+
} = await runKeyFilesLlm({
611621
client: args.client,
612622
parentSessionId: args.parentSessionId,
613623
projectPath,
614624
prompt,
615625
deadline: args.deadline,
616626
fallbackModels: args.fallbackModels,
617627
});
628+
childSessionId = agentSessionId;
618629
// The LLM call completed (tokens spent) — record before validation,
619630
// which is post-processing that can fail independently.
620631
recordKeyFilesInvocation({ status: "completed", messages: llmMessages });
@@ -629,8 +640,10 @@ export async function runKeyFilesTask(args: {
629640
log(`[key-files] LLM validation failed: ${getErrorMessage(error)}`);
630641
throw error;
631642
}
632-
if (validated.no_change)
643+
if (validated.no_change) {
644+
taskCompleted = true;
633645
return { committedVersion: null, candidates: candidates.length, noChange: true };
646+
}
634647
const committedVersion = commitKeyFiles({
635648
db: args.db,
636649
projectPath,
@@ -640,8 +653,16 @@ export async function runKeyFilesTask(args: {
640653
leaseHolderId: args.holderId,
641654
});
642655
renewLease(args.db, args.holderId);
656+
taskCompleted = true;
643657
return { committedVersion, candidates: candidates.length, noChange: false };
644658
} finally {
645659
clearInterval(leaseInterval);
660+
// Delete the child session only on full success; retain on any failure
661+
// (validation/commit) for debugging, unless keep_subagents is set.
662+
if (childSessionId && taskCompleted && !shouldKeepSubagents()) {
663+
await args.client.session
664+
.delete({ path: { id: childSessionId } })
665+
.catch(() => undefined);
666+
}
646667
}
647668
}

packages/plugin/src/features/magic-context/overflow-detection.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,10 @@ const LIMIT_EXTRACTION_PATTERNS: ReadonlyArray<RegExp> = [
6969
];
7070

7171
/** Minimum plausible context limit. Anything smaller is probably a match
72-
* against an unrelated number in the error (e.g., error code). */
72+
* against an unrelated number in the error (e.g., error code). Kept at 1024
73+
* (NOT the [20k,3M] trusted-limit band): overflow detection honors a provider
74+
* EXPLICITLY stating its limit, and real small-context models exist (4096/8192
75+
* local llama.cpp) — raising this floor would discard a legitimate signal. */
7376
const MIN_PLAUSIBLE_LIMIT = 1024;
7477
/** Maximum plausible context limit. Anything larger is very likely a false
7578
* match against a token-count field rather than a limit. */

packages/plugin/src/shared/model-suggestion-retry.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import { parseProviderModel } from "./resolve-fallbacks";
66

77
type Client = ReturnType<typeof createOpencodeClient>;
88

9+
/** Max time to wait for the best-effort child-session abort HTTP call before
10+
* giving up on its response (the abort still proceeds server-side). Keeps a
11+
* wedged abort endpoint from masking the original timeout/abort error. */
12+
const ABORT_CALL_TIMEOUT_MS = 3000;
13+
914
type PromptBody = {
1015
model?: { providerID: string; modelID: string };
1116
[key: string]: unknown;
@@ -171,7 +176,15 @@ async function promptWithTimeout(
171176
*/
172177
async function abortChildRun(client: Client, sessionId: string): Promise<void> {
173178
try {
174-
await client.session.abort({ path: { id: sessionId } });
179+
// Bound the abort call: it's best-effort cleanup, and if the abort
180+
// endpoint itself stalls (the runner is wedged) an unbounded await here
181+
// would hang the caller and MASK the original timeout/abort error that we
182+
// still need to surface. Race against a short timer; the abort keeps
183+
// running server-side regardless of whether we wait for its response.
184+
await Promise.race([
185+
client.session.abort({ path: { id: sessionId } }),
186+
new Promise<void>((resolve) => setTimeout(resolve, ABORT_CALL_TIMEOUT_MS)),
187+
]);
175188
} catch (error) {
176189
log(`[model-retry] child session abort failed for ${sessionId}: ${String(error)}`);
177190
}

0 commit comments

Comments
 (0)