diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 111c84929..79bb9003a 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -167,7 +167,7 @@ Higher-tier models with longer cache windows benefit from a longer TTL. Setting | `execute_threshold_percentage` | `number` (20–90) or `object` | `65` | Context usage that forces queued ops to execute. Capped at 90% of the output-reserved safe window, leaving about 10% for mid-turn input growth. Supports per-model maps. | | `execute_threshold_tokens` | `object` (per-model map) | — | **Optional absolute-tokens variant of `execute_threshold_percentage`.** Per-model map (e.g. `{ "default": 150000, "github-copilot/gpt-5.2-codex": 40000 }`). When set for a model, overrides the percentage-based threshold for that model. Clamped to `90% × context_limit` with a warn log. Requires a resolvable context limit — falls through to percentage if unavailable. See below. | | `clear_reasoning_age` | `number` | `50` | Clear thinking/reasoning blocks older than N tags. | -| `historian_timeout_ms` | `number` | `300000` | Timeout per historian call (ms). | +| `historian_timeout_ms` | `number` | `600000` | Timeout per historian call (ms). | | `history_budget_percentage` | `number` (0.05–0.5) | `0.15` | Fraction of usable context (`context_limit × execute_threshold`) reserved for the history block. Triggers compression when exceeded. | | `compaction.enabled` | `boolean` | `true` | When `false`, use compaction-off mode: keep Magic Context's knowledge layer and let native compaction (or nothing) own the context window. Boot-resolved; restart after changing it. See below. | | `commit_cluster_trigger` | `object` | See below | Controls the commit-cluster historian trigger. | diff --git a/assets/magic-context.schema.json b/assets/magic-context.schema.json index d9581cbbb..dc3767bbd 100644 --- a/assets/magic-context.schema.json +++ b/assets/magic-context.schema.json @@ -1271,8 +1271,8 @@ "maximum": 0.5 }, "historian_timeout_ms": { - "default": 300000, - "description": "Timeout for each historian prompt call in milliseconds (default: 300000)", + "default": 600000, + "description": "Timeout for each historian prompt call in milliseconds (default: 600000)", "type": "number", "minimum": 60000 }, diff --git a/packages/docs/src/content/docs/reference/configuration.md b/packages/docs/src/content/docs/reference/configuration.md index daed71ffd..ef0a54164 100644 --- a/packages/docs/src/content/docs/reference/configuration.md +++ b/packages/docs/src/content/docs/reference/configuration.md @@ -100,7 +100,7 @@ The background agent that condenses old conversation into compact history. | `historian.two_pass` | boolean | `false` | Run a second editor pass over historian output to clean low-signal U: lines and cross-compartment duplicates. Adds ~1 extra API call and ~1.3x cost per historian run. Useful for models without extended thinking support. (default: false) | | `historian.thinking_level` | `"off"` \\| `"minimal"` \\| `"low"` \\| `"medium"` \\| `"high"` \\| `"xhigh"` \\| `"max"` | — | Pi only: explicit thinking level passed as --thinking to Pi historian subagent invocations. Required when using reasoning models (e.g. github-copilot/gpt-5.4) because Pi's default thinking-level resolution can pick a value the provider rejects. OpenCode users set variant instead. Valid: off \| minimal \| low \| medium \| high \| xhigh \| max | | `historian.disallowed_tools` | `"*"` \\| `"read"` \\| `"aft_outline"` \\| `"aft_zoom"` \\| `"aft_search"`[] | `[]` | OpenCode only. Tools to REMOVE from the historian's default allow-list [read, aft_outline, aft_zoom, aft_search]. Applies to both historian and historian-editor agents. Use ["*"] to strip all tool definitions from the model request — this prevents weak instruction-following models (e.g. mistral-small-latest) from entering tool-calling loops. Individual tool names remove just that tool. Note: a user-supplied historian.permission override can re-allow a tool that disallowed_tools removed — disallowed_tools sets the baseline, permission overrides take precedence. (default: []) | -| `historian_timeout_ms` | number (60000–) | `300000` | Timeout for each historian prompt call in milliseconds (default: 300000) | +| `historian_timeout_ms` | number (60000–) | `600000` | Timeout for each historian prompt call in milliseconds (default: 600000) | | `commit_cluster_trigger` | object | — | Commit-cluster trigger: fire historian when enough commit clusters accumulate in the unsummarized tail | | `commit_cluster_trigger.enabled` | boolean | `true` | Enable commit-cluster based historian triggering (default: true) | | `commit_cluster_trigger.min_clusters` | number (1–) | `3` | Minimum commit clusters required to trigger historian (min: 1, default: 3) | diff --git a/packages/plugin/src/config/schema/magic-context.test.ts b/packages/plugin/src/config/schema/magic-context.test.ts index 651d51f24..2c3ebeedf 100644 --- a/packages/plugin/src/config/schema/magic-context.test.ts +++ b/packages/plugin/src/config/schema/magic-context.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "bun:test"; import { - DEFAULT_HISTORIAN_TIMEOUT_MS, DEFAULT_HISTORY_BUDGET_PERCENTAGE, DEFAULT_LOCAL_EMBEDDING_MODEL, type MagicContextConfig, @@ -25,7 +24,7 @@ describe("MagicContextConfigSchema", () => { protected_tags: 20, clear_reasoning_age: 50, history_budget_percentage: DEFAULT_HISTORY_BUDGET_PERCENTAGE, - historian_timeout_ms: DEFAULT_HISTORIAN_TIMEOUT_MS, + historian_timeout_ms: 600_000, embedding: { provider: "local", model: DEFAULT_LOCAL_EMBEDDING_MODEL, diff --git a/packages/plugin/src/config/schema/magic-context.ts b/packages/plugin/src/config/schema/magic-context.ts index 11ed67f29..8f7881158 100644 --- a/packages/plugin/src/config/schema/magic-context.ts +++ b/packages/plugin/src/config/schema/magic-context.ts @@ -16,7 +16,8 @@ export const DEFAULT_EXECUTE_THRESHOLD_PERCENTAGE = 65; // escalation derives above the effective threshold and the 95% wall stays fixed. export const EXECUTE_THRESHOLD_CAP_MESSAGE = "execute_threshold is capped at 90% for cache safety: output capacity is reserved from the usable context window, and the remaining 10% absorbs mid-turn growth before the absolute 95% emergency wall. Use a value between 20 and 90."; -export const DEFAULT_HISTORIAN_TIMEOUT_MS = 300_000; +export const DEFAULT_HISTORIAN_TIMEOUT_MS = 600_000; +export const MAX_HISTORIAN_PROMPT_ATTEMPTS = 3; export const DEFAULT_HISTORY_BUDGET_PERCENTAGE = 0.15; export const DEFAULT_LOCAL_EMBEDDING_MODEL = "Xenova/all-MiniLM-L6-v2"; @@ -784,7 +785,7 @@ export const MagicContextConfigSchema = z .number() .min(60_000) .default(DEFAULT_HISTORIAN_TIMEOUT_MS) - .describe("Timeout for each historian prompt call in milliseconds (default: 300000)"), + .describe("Timeout for each historian prompt call in milliseconds (default: 600000)"), commit_cluster_trigger: z .object({ enabled: z diff --git a/packages/plugin/src/features/magic-context/dreamer/retrospective-orphan-sweep.test.ts b/packages/plugin/src/features/magic-context/dreamer/retrospective-orphan-sweep.test.ts index dddc76b85..63961ef4f 100644 --- a/packages/plugin/src/features/magic-context/dreamer/retrospective-orphan-sweep.test.ts +++ b/packages/plugin/src/features/magic-context/dreamer/retrospective-orphan-sweep.test.ts @@ -6,7 +6,11 @@ import { Database } from "../../../shared/sqlite"; import { closeQuietly } from "../../../shared/sqlite-helpers"; import { CURATE_CHILD_TITLE, + HISTORIAN_CHILD_TITLE, + historianOrphanStaleMs, + historianOrphanSweepTitleMatches, MAINTAIN_DOCS_CHILD_TITLE, + privacyOrphanSweepTitleMatches, REFRESH_PRIMERS_CHILD_TITLE, RETROSPECTIVE_CHILD_TITLE, retrospectiveOrphanStaleMs, @@ -48,6 +52,21 @@ describe("retrospectiveOrphanStaleMs", () => { expect(retrospectiveOrphanStaleMs([10, 45, undefined])).toBe(135 * 60_000); expect(retrospectiveOrphanStaleMs(undefined)).toBe(60 * 60_000); }); + + test("covers every outer retry, fallback, and model-suggestion call", () => { + expect(historianOrphanStaleMs(600_000, 3)).toBe(12 * 60 * 60_000); + }); +}); + +describe("orphan sweep title groups", () => { + test("keeps historian and privacy titles in separate groups", () => { + const privacyTitles = privacyOrphanSweepTitleMatches(); + const historianTitles = historianOrphanSweepTitleMatches(); + + expect(privacyTitles.exact).toContain(RETROSPECTIVE_CHILD_TITLE); + expect(privacyTitles.exact).not.toContain(HISTORIAN_CHILD_TITLE); + expect(historianTitles).toEqual({ exact: [HISTORIAN_CHILD_TITLE], prefixes: [] }); + }); }); describe("sweepOrphanedRetrospectiveChildren", () => { @@ -75,7 +94,8 @@ describe("sweepOrphanedRetrospectiveChildren", () => { insert(db, "old", RETROSPECTIVE_CHILD_TITLE, DIR, now - staleMs - 6); insert(db, "old-curate", CURATE_CHILD_TITLE, DIR, now - staleMs - 5); insert(db, "old-docs", MAINTAIN_DOCS_CHILD_TITLE, DIR, now - staleMs - 4); - insert(db, "old-refresh", REFRESH_PRIMERS_CHILD_TITLE, DIR, now - staleMs - 3); + insert(db, "old-refresh", REFRESH_PRIMERS_CHILD_TITLE, DIR, now - staleMs - 4); + insert(db, "old-historian", HISTORIAN_CHILD_TITLE, DIR, now - staleMs - 3); insert( db, "old-compile", @@ -92,6 +112,7 @@ describe("sweepOrphanedRetrospectiveChildren", () => { ); // recent child (live run) → NOT swept insert(db, "fresh", RETROSPECTIVE_CHILD_TITLE, DIR, now - 1000); + insert(db, "fresh-historian", HISTORIAN_CHILD_TITLE, DIR, now - 1000); // old but a different title → NOT swept insert(db, "other-title", "magic-context-dream-verify", DIR, now - staleMs - 1); // old retrospective but ANOTHER directory → NOT swept @@ -112,10 +133,11 @@ describe("sweepOrphanedRetrospectiveChildren", () => { "old-curate", "old-docs", "old-refresh", + "old-historian", "old-compile", "old-confirm", ]); - expect(count).toBe(7); + expect(count).toBe(8); }); test("treats a delete error (404 / already removed) as success", async () => { diff --git a/packages/plugin/src/features/magic-context/dreamer/retrospective-orphan-sweep.ts b/packages/plugin/src/features/magic-context/dreamer/retrospective-orphan-sweep.ts index 35b283a1c..c1db68f6a 100644 --- a/packages/plugin/src/features/magic-context/dreamer/retrospective-orphan-sweep.ts +++ b/packages/plugin/src/features/magic-context/dreamer/retrospective-orphan-sweep.ts @@ -1,3 +1,4 @@ +import { MAX_HISTORIAN_PROMPT_ATTEMPTS } from "../../../config/schema/magic-context"; import type { PluginContext } from "../../../plugin/types"; import { log } from "../../../shared/logger"; import type { Database } from "../../../shared/sqlite"; @@ -6,7 +7,7 @@ import type { DreamTaskName } from "./task-registry"; type OpencodeClient = PluginContext["client"]; /** - * Privacy backstop for dreamer children that carry raw user or project text. + * Privacy backstop for internal children that carry raw user or project text. * * These child sessions normally delete themselves in `finally`, but a hard * SIGKILL/OOM BETWEEN session-create and that delete would leave their prompts @@ -15,10 +16,13 @@ type OpencodeClient = PluginContext["client"]; * CONCURRENCY: `session.delete` has no cross-process "active session" lease (OC * peer confirmed), so the ONLY safe filter is AGE — a child older than any * legitimate run cannot belong to a live run on another OpenCode process. + * Callers sweep dreamer/privacy titles and historian titles separately because + * their maximum legitimate runtimes are calculated from different budgets. * OpenCode sets `title` + `time_created` immediately at create (not lazily), so * the age gate is airtight. 404 on delete = already-swept = success. */ export const RETROSPECTIVE_CHILD_TITLE = "magic-context-dream-retrospective"; +export const HISTORIAN_CHILD_TITLE = "magic-context-compartment"; export const USER_MEMORIES_CHILD_TITLE = "magic-context-dream-user-memories"; export const CURATE_CHILD_TITLE = "magic-context-dream-curate"; export const MAINTAIN_DOCS_CHILD_TITLE = "magic-context-dream-maintain-docs"; @@ -43,6 +47,7 @@ export interface PrivacySensitiveChildTitleMatches { export const PRIVACY_SENSITIVE_CHILD_TITLE_MATCHES: PrivacySensitiveChildTitleMatches = { exact: [ RETROSPECTIVE_CHILD_TITLE, + HISTORIAN_CHILD_TITLE, USER_MEMORIES_CHILD_TITLE, CURATE_CHILD_TITLE, MAINTAIN_DOCS_CHILD_TITLE, @@ -50,6 +55,20 @@ export const PRIVACY_SENSITIVE_CHILD_TITLE_MATCHES: PrivacySensitiveChildTitleMa ], prefixes: [SMART_NOTE_COMPILE_CHILD_TITLE_PREFIX, SMART_NOTE_CONFIRM_CHILD_TITLE_PREFIX], }; +const PRIMARY_AND_SUGGESTED_CALLS_PER_CANDIDATE = 2; + +export function privacyOrphanSweepTitleMatches(): PrivacySensitiveChildTitleMatches { + return { + exact: PRIVACY_SENSITIVE_CHILD_TITLE_MATCHES.exact.filter( + (title) => title !== HISTORIAN_CHILD_TITLE, + ), + prefixes: PRIVACY_SENSITIVE_CHILD_TITLE_MATCHES.prefixes, + }; +} + +export function historianOrphanSweepTitleMatches(): PrivacySensitiveChildTitleMatches { + return { exact: [HISTORIAN_CHILD_TITLE], prefixes: [] }; +} /** Stale threshold from task timeout(s): max(60min, maxTimeout×3) — comfortably * past every swept child type so a live child is never swept. */ @@ -67,6 +86,17 @@ export function retrospectiveOrphanStaleMs( return Math.max(60 * 60_000, timeoutMs * 3); } +export function historianOrphanStaleMs(timeoutMs: number, fallbackCount: number): number { + const candidateCount = Math.max(0, fallbackCount) + 1; + const attemptBudgetMinutes = + (timeoutMs * + MAX_HISTORIAN_PROMPT_ATTEMPTS * + PRIMARY_AND_SUGGESTED_CALLS_PER_CANDIDATE * + candidateCount) / + 60_000; + return retrospectiveOrphanStaleMs(attemptBudgetMinutes); +} + interface OrphanRow { id: string; time_created: number; diff --git a/packages/plugin/src/hooks/magic-context/compartment-runner-historian.ts b/packages/plugin/src/hooks/magic-context/compartment-runner-historian.ts index 07d188a59..67f449799 100644 --- a/packages/plugin/src/hooks/magic-context/compartment-runner-historian.ts +++ b/packages/plugin/src/hooks/magic-context/compartment-runner-historian.ts @@ -1,7 +1,10 @@ import { mkdirSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { HISTORIAN_AGENT, HISTORIAN_EDITOR_AGENT } from "../../agents/historian"; -import { DEFAULT_HISTORIAN_TIMEOUT_MS } from "../../config/schema/magic-context"; +import { + DEFAULT_HISTORIAN_TIMEOUT_MS, + MAX_HISTORIAN_PROMPT_ATTEMPTS, +} from "../../config/schema/magic-context"; import { openDatabase } from "../../features/magic-context/storage"; import type { SubagentKind } from "../../features/magic-context/storage-subagent-invocations"; import { @@ -19,7 +22,6 @@ import { getProjectMagicContextHistorianDir, } from "../../shared/data-path"; import { describeError, getErrorMessage } from "../../shared/error-message"; -import { shouldKeepSubagents } from "../../shared/keep-subagents"; import { isRecord } from "../../shared/record-type-guard"; import type { Database } from "../../shared/sqlite"; import { createChildSessionWithFence } from "./child-session-spawn"; @@ -46,7 +48,7 @@ import { function historianResponseDumpDir(directory: string): string { return getProjectMagicContextHistorianDir(directory); } -const MAX_HISTORIAN_RETRIES = 2; +const MAX_HISTORIAN_RETRIES = MAX_HISTORIAN_PROMPT_ATTEMPTS - 1; interface HistorianModelOverride { providerID: string; @@ -325,9 +327,6 @@ async function runHistorianPrompt(args: { let agentSessionId: string | null = null; const startedAt = Date.now(); let invocationRecorded = false; - // Keep FAILED historian child sessions for debugging (the model output, the - // exact prompt, and the error are all inspectable in the child session). Only - // delete on SUCCESS, where the result is already persisted as a compartment. let outcomeOk = false; const recordInvocation = (params: { @@ -489,23 +488,15 @@ async function runHistorianPrompt(args: { error: `Historian failed while processing this session: ${desc.brief}`, }; } finally { - // Delete the child session ONLY on success. On failure, keep it so the - // failed model output / prompt / error can be inspected for debugging - // (the run is already recorded as failed in subagent_invocations + - // historian_runs; the live child session is the missing piece). A periodic - // sweep can GC old failed child sessions later if needed. - if (agentSessionId && outcomeOk && !shouldKeepSubagents()) { - await client.session.delete({ path: { id: agentSessionId } }).catch((e: unknown) => { - shared.sessionLog( - parentSessionId, - "compartment agent: session cleanup failed", - getErrorMessage(e), - ); - }); - } else if (agentSessionId && (!outcomeOk || shouldKeepSubagents())) { + // OpenCode can persist detached summary/step parts after prompt completion + // and even after the session reports idle. Immediate deletion races those + // writes and produces foreign-key failures in opencode.db. Keep the child + // here; the age-gated orphan sweep removes it after every legitimate writer + // has had ample time to finish. + if (agentSessionId) { shared.sessionLog( parentSessionId, - `historian: KEEPING child session ${agentSessionId} (${outcomeOk ? "keep_subagents" : "failed"}) — not deleted`, + `historian: KEEPING child session ${agentSessionId} (${outcomeOk ? "deferred cleanup" : "failed"}) — not deleted`, ); } } diff --git a/packages/plugin/src/hooks/magic-context/compartment-runner.test.ts b/packages/plugin/src/hooks/magic-context/compartment-runner.test.ts index f9c603df2..a3e98dec6 100644 --- a/packages/plugin/src/hooks/magic-context/compartment-runner.test.ts +++ b/packages/plugin/src/hooks/magic-context/compartment-runner.test.ts @@ -1296,6 +1296,7 @@ describe("runCompartmentAgent", () => { }, ], })); + const statusSession = mock(async () => ({ data: { "ses-agent": { type: "idle" } } })); const deleteSession = mock(async () => ({})); const client = { @@ -1304,6 +1305,7 @@ describe("runCompartmentAgent", () => { create: createSession, prompt: promptSession, messages, + status: statusSession, delete: deleteSession, }, } as unknown as PluginContext["client"]; @@ -1327,6 +1329,8 @@ describe("runCompartmentAgent", () => { query: { directory: "/tmp/parent" }, }); expect(promptSession.mock.calls[0]?.[0]?.body.agent).toBe("historian"); + expect(statusSession).not.toHaveBeenCalled(); + expect(deleteSession).not.toHaveBeenCalled(); }); it("keeps a committed publish succeeded and signaled when post-commit project registration throws", async () => { diff --git a/packages/plugin/src/hooks/magic-context/empty-task-output.test.ts b/packages/plugin/src/hooks/magic-context/empty-task-output.test.ts new file mode 100644 index 000000000..c4a232806 --- /dev/null +++ b/packages/plugin/src/hooks/magic-context/empty-task-output.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { annotateEmptyTaskOutput, EMPTY_TASK_OUTPUT_SENTINEL } from "./empty-task-output"; + +describe("annotateEmptyTaskOutput", () => { + test("surfaces a completed native task that returned no final text", () => { + const output = { + output: '\n\n\n\n', + }; + + annotateEmptyTaskOutput("task", output); + + expect(output.output).toContain(EMPTY_TASK_OUTPUT_SENTINEL); + }); + + test("leaves non-empty and non-task outputs unchanged", () => { + const taskOutput = { output: "completed" }; + const toolOutput = { output: "" }; + + annotateEmptyTaskOutput("task", taskOutput); + annotateEmptyTaskOutput("read", toolOutput); + + expect(taskOutput.output).toBe("completed"); + expect(toolOutput.output).toBe(""); + }); + + test("leaves bare empty and non-completed task results unchanged", () => { + const outputs = [ + { output: "" }, + { output: '' }, + { output: '' }, + { output: '' }, + ]; + + for (const output of outputs) annotateEmptyTaskOutput("task", output); + + expect(outputs.every(({ output }) => !output.includes(EMPTY_TASK_OUTPUT_SENTINEL))).toBe( + true, + ); + }); + + test("does not throw when a completed empty task output is frozen", () => { + const output = Object.freeze({ + output: '', + }); + + expect(() => annotateEmptyTaskOutput("task", output)).not.toThrow(); + expect(output.output).not.toContain(EMPTY_TASK_OUTPUT_SENTINEL); + }); +}); diff --git a/packages/plugin/src/hooks/magic-context/empty-task-output.ts b/packages/plugin/src/hooks/magic-context/empty-task-output.ts new file mode 100644 index 000000000..5ed03badd --- /dev/null +++ b/packages/plugin/src/hooks/magic-context/empty-task-output.ts @@ -0,0 +1,19 @@ +import { isRecord } from "../../shared/record-type-guard"; + +export const EMPTY_TASK_OUTPUT_SENTINEL = ""; +const EMPTY_COMPLETED_TASK_RESULT = + /^]*\bstate="completed"[^>]*>[\s\S]*\s*<\/task_result>\s*<\/task>\s*$/; + +export function annotateEmptyTaskOutput(tool: string, output: unknown): void { + if (tool !== "task" || !isRecord(output)) return; + if (typeof output.output !== "string") return; + if (output.output.includes(EMPTY_TASK_OUTPUT_SENTINEL)) return; + if (!EMPTY_COMPLETED_TASK_RESULT.test(output.output)) return; + + Reflect.set( + output, + "output", + `${output.output}\n${EMPTY_TASK_OUTPUT_SENTINEL} +The subagent completed without a final text response. Context-fill truncation may have omitted its final output, or its provider may have emitted reasoning only; inspect the child session and retry with a low-reasoning model or variant.`, + ); +} diff --git a/packages/plugin/src/hooks/magic-context/hook-handlers.test.ts b/packages/plugin/src/hooks/magic-context/hook-handlers.test.ts index 9d8ae09f1..181130bfe 100644 --- a/packages/plugin/src/hooks/magic-context/hook-handlers.test.ts +++ b/packages/plugin/src/hooks/magic-context/hook-handlers.test.ts @@ -13,6 +13,7 @@ import { } from "../../features/magic-context/storage-meta-persisted"; import { Database } from "../../shared/sqlite"; import { closeQuietly } from "../../shared/sqlite-helpers"; +import { EMPTY_TASK_OUTPUT_SENTINEL } from "./empty-task-output"; import { createChatMessageHook, createEventHook, @@ -34,6 +35,21 @@ function createTestHook(db: Database): ReturnType { + test("native task hook surfaces an empty completed child result", async () => { + const db = createTestDb(); + try { + const output = { + output: '\n\n\n\n', + }; + + await createTestHook(db)({ tool: "task", sessionID: "ses-parent" }, output); + + expect(output.output).toContain(EMPTY_TASK_OUTPUT_SENTINEL); + } finally { + closeQuietly(db); + } + }); + test("rust mode forwards todo state to the module without changing TS capture", async () => { const db = createTestDb(); try { diff --git a/packages/plugin/src/hooks/magic-context/hook-handlers.ts b/packages/plugin/src/hooks/magic-context/hook-handlers.ts index d318046cc..284716229 100644 --- a/packages/plugin/src/hooks/magic-context/hook-handlers.ts +++ b/packages/plugin/src/hooks/magic-context/hook-handlers.ts @@ -35,6 +35,7 @@ import { decideChannel1, toolOutputTokens, } from "./ctx-reduce-nudge"; +import { annotateEmptyTaskOutput } from "./empty-task-output"; import { getMessageUpdatedAssistantInfo, getMessageUpdatedInfo, @@ -533,6 +534,7 @@ export function createToolExecuteAfterHook(args: { // boundary. The queue helper re-checks the read-only mid-turn signal, // so this is a no-op until the assistant is actually idle. await flushIgnoredMessages(typedInput.sessionID); + annotateEmptyTaskOutput(typedInput.tool, output); if (typedInput.tool === "ctx_reduce") { // Mark the Channel 1 baseline dirty so the next nudge re-measures the diff --git a/packages/plugin/src/index-refresh.test.ts b/packages/plugin/src/index-refresh.test.ts index f965dfda7..4f3459e75 100644 --- a/packages/plugin/src/index-refresh.test.ts +++ b/packages/plugin/src/index-refresh.test.ts @@ -14,6 +14,17 @@ describe("plugin model-limit cache warmup", () => { }); }); +describe("historian timer fallback budget", () => { + test("counts a string fallback as one normalized model", () => { + const source = readFileSync(join(import.meta.dir, "index.ts"), "utf8"); + + expect(source).toMatch( + /resolveFallbackChain\(\s*pluginConfig\.historian\?\.fallback_models,?\s*\)\.length/, + ); + expect(source).not.toContain("pluginConfig.historian?.fallback_models?.length"); + }); +}); + describe("buildHiddenAgentConfig", () => { test("clamps maxSteps overrides above the hard cap", () => { const config = buildHiddenAgentConfig("prompt", ["read"], 40, { maxSteps: 100_000 }); diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 7629492ab..aa6690f52 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -64,6 +64,7 @@ import { setKeepSubagents } from "./shared/keep-subagents"; import { log } from "./shared/logger"; import { refreshModelLimitsFromApi } from "./shared/models-dev-cache"; import { createPromptSurfaceRuntime } from "./shared/prompt-surface-runtime"; +import { resolveFallbackChain } from "./shared/resolve-fallbacks"; import { MagicContextRpcServer } from "./shared/rpc-server"; import { closeQuietly } from "./shared/sqlite-helpers"; import { setStoragePrivatePermissionEnforcement } from "./shared/storage-permissions"; @@ -367,6 +368,11 @@ const server: Plugin = async (ctx) => { projectIdentity: timerProjectIdentity, client: ctx.client, dreamerConfig: dreamerRunnable ? pluginConfig.dreamer : undefined, + historianTimeoutMs: pluginConfig.historian_timeout_ms, + historianFallbackCount: resolveFallbackChain( + pluginConfig.historian?.fallback_models, + ).length, + keepSubagents: pluginConfig.keep_subagents, language: pluginConfig.language, transformMode: pluginConfig.transform_mode, embeddingConfig: pluginConfig.embedding, diff --git a/packages/plugin/src/plugin/dream-timer.test.ts b/packages/plugin/src/plugin/dream-timer.test.ts index d6d0486a0..906c57f2d 100644 --- a/packages/plugin/src/plugin/dream-timer.test.ts +++ b/packages/plugin/src/plugin/dream-timer.test.ts @@ -119,6 +119,33 @@ describe("dream-timer message-history maintenance (static)", () => { }); }); +describe("dream-timer privacy orphan sweep ordering (static)", () => { + const source = readFileSync(join(import.meta.dir, "dream-timer.ts"), "utf8"); + const sweep = source.slice( + source.indexOf("async function sweepProject("), + source.indexOf("async function runCompiledSmartNoteSweep("), + ); + + test("derives privacy timeouts before task scheduling can reject", () => { + const timeoutIndex = sweep.indexOf("const privacySweepTimeouts = runtimeConfigs"); + const schedulerIndex = sweep.indexOf("await runDueTasksForProject("); + + expect(timeoutIndex).toBeGreaterThan(0); + expect(timeoutIndex).toBeLessThan(schedulerIndex); + }); + + test("sweeps historian and privacy titles with separate stale windows", () => { + const orphanSweep = source.slice( + source.indexOf("async function sweepOrphanedChildSessions("), + source.indexOf("async function runCompiledSmartNoteSweep("), + ); + const sweepCalls = orphanSweep.match(/sweepOrphanedRetrospectiveChildren\(\{/g) ?? []; + + expect(sweepCalls).toHaveLength(2); + expect(orphanSweep).not.toContain("staleMs: Math.max("); + }); +}); + describe("dream-timer git commit backlog drain (static)", () => { const source = readFileSync(join(import.meta.dir, "dream-timer.ts"), "utf8"); diff --git a/packages/plugin/src/plugin/dream-timer.ts b/packages/plugin/src/plugin/dream-timer.ts index 930281d6a..7dea7ffb0 100644 --- a/packages/plugin/src/plugin/dream-timer.ts +++ b/packages/plugin/src/plugin/dream-timer.ts @@ -1,12 +1,14 @@ import { statSync } from "node:fs"; -import type { DreamerConfig } from "../config/schema/magic-context"; +import { DEFAULT_HISTORIAN_TIMEOUT_MS, type DreamerConfig } from "../config/schema/magic-context"; import type { ClassifyModuleClient } from "../features/magic-context/dreamer/classify"; import { acquireLease, releaseLease } from "../features/magic-context/dreamer/lease"; import { openOpenCodeDb } from "../features/magic-context/dreamer/open-opencode-db"; import { + historianOrphanStaleMs, + historianOrphanSweepTitleMatches, PRIVACY_SENSITIVE_CHILD_TASKS, - PRIVACY_SENSITIVE_CHILD_TITLE_MATCHES, + privacyOrphanSweepTitleMatches, retrospectiveOrphanStaleMs, sweepOrphanedRetrospectiveChildren, } from "../features/magic-context/dreamer/retrospective-orphan-sweep"; @@ -77,6 +79,9 @@ interface ProjectRegistration { projectIdentity: string; client: PluginContext["client"]; dreamerConfig?: DreamerConfig; + historianTimeoutMs?: number; + historianFallbackCount?: number; + keepSubagents?: boolean; language?: string; gitCommitIndexing?: { enabled: boolean; @@ -333,7 +338,8 @@ async function runProjectMaintenance( const projectMaintenanceEnabled = Boolean(reg.dreamerConfig && reg.dreamerConfig.disable !== true) || reg.memoryEnabled === true || - reg.gitCommitIndexing?.enabled === true; + reg.gitCommitIndexing?.enabled === true || + reg.historianTimeoutMs !== undefined; if (!projectMaintenanceEnabled) return; await reg.ensureRegistered(reg.directory, db); @@ -416,9 +422,19 @@ async function sweepProject( } if (!dreamingEnabled || !dreamerConfig) { + await sweepOrphanedChildSessions(reg, []); return; } + // Resolve the privacy age budget before any awaited scheduler work. If + // scheduling rejects, the orphan sweep must still protect a legitimately + // long-running child with its configured timeout rather than the 60m floor. + const runtimeConfigs = buildDreamTaskRuntimeConfigs(dreamerConfig, reg.language); + const privacySweepTimeouts = runtimeConfigs + .filter((config) => + (PRIVACY_SENSITIVE_CHILD_TASKS as readonly string[]).includes(config.task), + ) + .map((config) => config.timeoutMinutes); try { await runCompiledSmartNoteSweep(reg, db); @@ -427,7 +443,6 @@ async function sweepProject( // runs due tasks grouped by conflict-domain under keyed leases. The // executor runs in THIS registration's own checkout (not a sibling // worktree the shared git: identity might resolve to). - const runtimeConfigs = buildDreamTaskRuntimeConfigs(dreamerConfig, reg.language); const executor = createDreamTaskExecutor({ client: reg.client, sessionDirectory: reg.directory, @@ -463,35 +478,46 @@ async function sweepProject( if (ran > 0) { log(`[dreamer] timer tick (${origin}) ${reg.projectIdentity} — ran ${ran} task(s)`); } + } catch (error) { + log(`[dreamer] timer-triggered task scheduling failed for ${reg.projectIdentity}:`, error); + } + await sweepOrphanedChildSessions(reg, privacySweepTimeouts); +} - // PRIVACY backstop: remove crash-orphaned children carrying raw user or - // project text only after the longest swept task's timeout has elapsed. - // OpenCode-only (Pi subprocess children die with their process); skip - // when no opencode.db. - const privacySweepTimeouts = runtimeConfigs - .filter((c) => (PRIVACY_SENSITIVE_CHILD_TASKS as readonly string[]).includes(c.task)) - .map((c) => c.timeoutMinutes); - const ocDb = openOpenCodeDb(); - if (ocDb) { - try { - await sweepOrphanedRetrospectiveChildren({ - opencodeDb: ocDb, - client: reg.client, - sessionDirectory: reg.directory, - staleMs: retrospectiveOrphanStaleMs(privacySweepTimeouts), - titleMatches: PRIVACY_SENSITIVE_CHILD_TITLE_MATCHES, - }); - } catch (sweepError) { - log( - `[dreamer] retrospective orphan sweep failed for ${reg.projectIdentity}:`, - sweepError, - ); - } finally { - closeQuietly(ocDb); - } +async function sweepOrphanedChildSessions( + reg: ProjectRegistration, + privacySweepTimeouts: readonly (number | undefined)[], +): Promise { + const opencodeDb = openOpenCodeDb(); + if (!opencodeDb) return; + + try { + // Dreamer privacy titles use task timeout ×3. Historian children have a + // separate retry/fallback budget; sharing the maximum made raw dreamer + // sessions linger for hours. keep_subagents exempts historian only. + await sweepOrphanedRetrospectiveChildren({ + opencodeDb, + client: reg.client, + sessionDirectory: reg.directory, + staleMs: retrospectiveOrphanStaleMs(privacySweepTimeouts), + titleMatches: privacyOrphanSweepTitleMatches(), + }); + if (reg.keepSubagents !== true) { + await sweepOrphanedRetrospectiveChildren({ + opencodeDb, + client: reg.client, + sessionDirectory: reg.directory, + staleMs: historianOrphanStaleMs( + reg.historianTimeoutMs ?? DEFAULT_HISTORIAN_TIMEOUT_MS, + reg.historianFallbackCount ?? 0, + ), + titleMatches: historianOrphanSweepTitleMatches(), + }); } } catch (error) { - log(`[dreamer] timer-triggered task scheduling failed for ${reg.projectIdentity}:`, error); + log(`[dreamer] child-session orphan sweep failed for ${reg.projectIdentity}:`, error); + } finally { + closeQuietly(opencodeDb); } }