Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/desktop/src/main/engine/local-models/run-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@
* server could forward it; see docs/reviews/2026-10-27-lm5-destination-policy.md.
*/

/**
* Whether a thread is local-only (#380): it carries a local target, valid or not. Main keeps every
* auxiliary model call and cloud fallback off for such a thread at the point that would make it (Jev,
* credit fallback, naming, unattended runs), not only at the caller, so a new caller cannot leak it.
*/
export function localOnly(thread: { localTarget?: unknown } | undefined): boolean {
return thread !== undefined && thread.localTarget !== undefined;
}

export interface ConfigLayerSource { type: string; file?: string; domain?: string; key?: string; id?: string; name?: string; dotCodexFolder?: string; profile?: string | null }
export interface ConfigLayer { name: ConfigLayerSource; version: string; config: unknown; disabledReason?: string | null }
export interface ConfigRead { config: Record<string, unknown>; origins: Record<string, { name: ConfigLayerSource; version: string }>; layers?: ConfigLayer[] | null }
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/main/engine/routing/decisions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,19 @@ export class Decisions {
private jevDisabled: string | null = null;
/** The last explicit Settings test. Status reads this without making a provider request. */
private lastTest: RoutingStatus["lastTest"];
/** Main's local-only rule (#380): threads no question may be asked about. */
private localOnly: (threadId: string) => boolean = () => false;

constructor(private readonly o: DecisionsOptions) {
this.secrets = o.secrets ?? new SecretStore(o.home, noCipher);
this.receipts = new ReceiptLog(path.join(o.home, "app", "decisions.jsonl"));
}

/** A question about a thread this names gets no connection: no key is resolved and nothing is sent. */
guardThreads(localOnly: (threadId: string) => boolean): void {
this.localOnly = localOnly;
}

/** Forget the resolved key/transport and any session-level rejection (after a key or policy change). */
reset(): void {
this.setupGeneration++;
Expand Down Expand Up @@ -184,6 +191,7 @@ export class Decisions {
async connect(set: QuestionSet, context: { threadId?: string; preview?: boolean } = {}): Promise<Connection> {
const generation = this.setupGeneration;
const model = this.model();
if (context.threadId && this.localOnly(context.threadId)) return { transport: null, model, unavailable: "Jev is never asked about a local thread." };
const s = await this.setup();
if (this.jevDisabled) return { transport: null, model, disabled: this.jevDisabled };
if (!s.transport) return { transport: null, model, unavailable: (s.unavailableReason ?? s.key.problem ?? "No Jev transport available") };
Expand Down
11 changes: 10 additions & 1 deletion apps/desktop/src/main/engine/runner.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { resolveCli } from "./cli-path.js";
import type { LocalExecution } from "./local-models/execution.js";
import { localOnly } from "./local-models/run-policy.js";
import { RestartAdmission } from "./restart-admission.js";
import fs from "node:fs";
import path from "node:path";
Expand Down Expand Up @@ -156,6 +157,8 @@ export class ThreadRunner {
mock: o.backends?.mock ?? new MockBackend(() => s().mock_script, o.home),
};
this.router = o.router ?? new Router({ home: o.home, policy: () => s().routing, listModels: (b) => this.listModels(b), secrets: o.secrets });
// Jev is never asked about a local thread, whichever caller asks (#380).
this.router.decisions.guardThreads(id => localOnly(o.store.thread(id)));
}

backend(id: BackendId): Backend {
Expand Down Expand Up @@ -329,6 +332,8 @@ export class ThreadRunner {
async sendUnattended(threadId: string, text: string): Promise<{ completed: boolean }> {
const thread = this.o.store.thread(threadId);
if (!thread || thread.mode !== "chat") throw new Error("An unattended turn must run in Chat, which is read-only.");
// Nobody is there to see a local failure or stop a local child: unattended local work stays unavailable, never rerouted.
if (localOnly(thread)) throw new Error("A routine can't run on a local model. Local turns need someone watching.");
this.unattended.add(threadId);
try {
await this.send(threadId, text);
Expand Down Expand Up @@ -965,6 +970,8 @@ export class ThreadRunner {

private nameThread(threadId: string, text: string, fallback: string, backend: Backend, model: string, reply?: string): void {
const current = this.o.store.thread(threadId);
// Naming is a separate model conversation: a local thread keeps its opening-message title.
if (localOnly(current)) return;
if (!backend.generateTitle || this.disposing || this.deleting.has(threadId) || current?.title !== fallback || current.titleSource === "manual") return;
const abort = new AbortController();
this.naming.set(threadId, abort);
Expand Down Expand Up @@ -1215,6 +1222,8 @@ export class ThreadRunner {
* and a second run could repeat it, so that turn keeps its failure card.
*/
private creditFallback(thread: Thread, error: string | undefined, turnItems: ThreadItem[]): string | undefined {
// A local turn never moves to a cloud model.
if (localOnly(thread)) return undefined;
if (!this.o.store.settings.model_fallback || !error || !isModelOutOfCredits(error)) return undefined;
if (turnItems.some((item) => item.kind === "assistant" || item.kind === "tool" || item.kind === "thinking")) return undefined;
const to = creditFallbackModel(thread.backend, thread.model);
Expand All @@ -1223,7 +1232,7 @@ export class ThreadRunner {

/** The fallback for a model that was refused for usage credits within CREDIT_SKIP_MS, or undefined. */
private recentCreditFallback(thread: Thread): string | undefined {
if (!this.o.store.settings.model_fallback) return undefined;
if (localOnly(thread) || !this.o.store.settings.model_fallback) return undefined;
const key = `${thread.backend}:${thread.model}`;
const at = this.outOfCredits.get(key);
if (at === undefined) return undefined;
Expand Down
43 changes: 43 additions & 0 deletions apps/desktop/test/runner-local-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,49 @@ test("a failed local turn stays failed: no credit fallback, no cloud retry", asy
} finally { await h.runner.dispose(); }
});

test("main's local-only rule holds where each auxiliary call is made, whichever caller reaches it", async () => {
const h = await setup();
try {
h.select();
const cloudThread = await h.runner.createThread(h.thread.projectId, { backend: "codex", model: "gpt-6-astra", auto: true });
const decisions = h.runner.router.decisions;
// Control: a cloud thread's question gets the injected Jev transport.
assert.ok((await decisions.connect("routing", { threadId: cloudThread.id })).transport);
for (const set of ["routing", "follow_up", "approval"] as const) {
const connection = await decisions.connect(set, { threadId: h.thread.id });
assert.equal(connection.transport, null, set);
assert.match((connection as { unavailable?: string }).unavailable ?? "", /never asked about a local thread/);
}
// The router itself, called directly rather than through the runner's own guard, sends nothing to Jev.
const local = h.store.thread(h.thread.id)!;
await h.runner.router.route({ thread: local, text: "read", items: [], project: { name: "p", branch: null } });
await h.runner.router.followUp({ thread: local, text: "read", items: [], project: { name: "p" } });
assert.equal(h.jevRequests.length, 0);

// Credit fallback: a cloud Fable thread falls back to Opus; the same thread carrying a local target never does.
const internals = h.runner as unknown as { creditFallback(t: unknown, e: string, i: unknown[]): string | undefined; recentCreditFallback(t: unknown): string | undefined;
outOfCredits: Map<string, number>; nameThread(id: string, text: string, fallback: string, backend: unknown, model: string): void; titleRuns: Set<Promise<void>> };
const fable = { ...local, backend: "claude", model: "fable", localTarget: undefined };
assert.equal(internals.creditFallback(fable, "This model requires usage credits", []), "opus");
assert.equal(internals.creditFallback({ ...fable, localTarget: h.target }, "This model requires usage credits", []), undefined);
internals.outOfCredits.set("claude:fable", Date.now());
assert.equal(internals.recentCreditFallback(fable), "opus");
assert.equal(internals.recentCreditFallback({ ...fable, localTarget: h.target }), undefined);

// Naming: a backend that can name is still never asked to name a local thread.
const titled = h.store.thread(h.thread.id)!;
internals.nameThread(h.thread.id, "read", titled.title, { generateTitle: async () => { h.counts.titles++; return "model title"; } }, MODEL);
await Promise.all([...internals.titleRuns]);
assert.equal(h.counts.titles, 0);
assert.equal(h.store.thread(h.thread.id)!.title, titled.title);

// A routine's unattended turn never runs on a local target, and is not rerouted to a cloud one.
h.runner.updateThread(h.thread.id, { mode: "chat" });
await assert.rejects(h.runner.sendUnattended(h.thread.id, "read"), /routine can't run on a local model/);
assert.deepEqual({ cloud: h.counts.cloud, local: h.counts.local, jev: h.jevRequests.length }, { cloud: 0, local: 0, jev: 0 });
} finally { await h.runner.dispose(); }
});

test("a forged, stale or unloadable persisted target never reaches a cloud backend", async () => {
const h = await setup();
try {
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/test/runner-routing-ceiling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ test("a blocked router decision cannot switch backends or discard a saved CLI se
runTurn: async () => { runs++; return { status: "completed" }; },
};
const router = {
decisions: { guardThreads: () => {} },
route: async () => ({
item: {
id: "blocked-route", kind: "route", backend: "claude", model: "opus", effort: "high", fast: false,
Expand Down
17 changes: 17 additions & 0 deletions docs/local-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,23 @@ redirect. No Codex setting constrains this. A loopback server that redirects can
wherever it points, as it could by forwarding. Evidence and the receipts are in the
[LM-5 slice 1 review](reviews/2026-10-27-lm5-destination-policy.md).

## LM-5 slice 2: the local-only rule at each auxiliary chokepoint

Status: on `main`, no UI, local execution still unadmitted (#380). Before this slice each caller checked
`thread.localTarget` itself: the runner skipped Auto, follow-ups, naming and credit fallback, and the approval
gate skipped Jev. A new caller could forget. Now `localOnly(thread)` in `engine/local-models/run-policy.ts`
(true for any persisted local target, valid or not) is also enforced where each call is made:

- **Jev.** `Decisions.connect` returns no connection for a question naming a local thread, before any key is
resolved or CLI probed, so routing, follow-up and approval questions all stop there. Settings' Try it names
no thread and is unaffected.
- **Credit fallback.** Both the after-failure fallback and the recent-refusal skip return nothing for a local
thread, whatever its backend and model claim.
- **Naming.** A local thread is never sent to a title model, even through a backend that can name.
- **Unattended turns.** `sendUnattended`, the routine path, refuses a local thread: nobody is there to see a
local failure or stop its child, and the turn is not rerouted to a cloud target.

The callers' own checks stay in place. Evidence: [LM-5 local-only chokepoints](reviews/2026-10-30-lm5-local-only-chokepoints.md).
## LM-5: context-window evidence

Status: on `main`, no UI, local execution still unadmitted (#380 AC6). The context window a local child
Expand Down
58 changes: 58 additions & 0 deletions docs/reviews/2026-10-30-lm5-local-only-chokepoints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# LM-5 local-only chokepoints (#380), 2026-10-30

Issue #380 remains open. This slice moves the no-cloud-fallback rule for local threads from each caller to the
point where each auxiliary call is made. It is cut from `main` at `f51ef7e`. It does not admit product local
execution: `main` still constructs no `LocalExecution`. It also qualifies no native runtime as offline.

## What changed

- **Predicate.** `localOnly(thread)` in `engine/local-models/run-policy.ts` is true for any persisted local
target, valid or not.
- **Jev.** `Decisions.guardThreads` lets the runner register that predicate. `Decisions.connect` then returns
`{ transport: null }` for any question naming a local thread, and returns before key resolution or CLI
detection. This covers routing, follow-up and approval.
- **Credit fallback.** `creditFallback` and `recentCreditFallback` return nothing for a local thread.
- **Naming.** `nameThread` returns before any title model is asked.
- **Unattended turns.** `sendUnattended` refuses a local thread with "A routine can't run on a local model".
It neither runs nor reroutes the turn.

The callers' existing guards are unchanged.

## Verification

New regression: `runner-local-models.test.ts`, "main's local-only rule holds where each auxiliary call is made,
whichever caller reaches it". It calls each chokepoint directly, bypassing the runner's caller checks:

| Chokepoint | Control (cloud) | Local thread |
| --- | --- | --- |
| `Decisions.connect` (`routing`) | transport returned | no transport, 0 Jev requests |
| `Decisions.connect` (`follow_up`, `approval`) | not run | no transport, 0 Jev requests |
| `Router.route` / `Router.followUp` called directly | not run | 0 Jev requests |
| `creditFallback` (Fable, "requires usage credits") | `opus` | none |
| `recentCreditFallback` (Fable refused within the hour) | `opus` | none |
| `nameThread` with a backend that can name | not run | 0 title calls, title unchanged |
| `sendUnattended` in Chat | not run | rejected; 0 local, 0 cloud, 0 Jev |

**Negative controls.** Each guard was disabled in turn in the compiled `dist` output, and the regression failed
each time:

- Decisions guard: `AssertionError: routing`.
- `creditFallback`: strict-equal failure.
- `recentCreditFallback`: strict-equal failure.
- Naming: strict-equal failure.
- Unattended: `Missing expected rejection`.

The original files were then restored, verified byte for byte with `cmp`, and the case passed again.

**Results:**

- Focused suites (`runner-local-models`, `local-run-policy`, `routing`): 56/56.
- Full-check results are recorded in the PR.

## Still open on #380

- the native network-denied offline canary (AC2), which needs Val's authorization and an installed local coding
model;
- runtime effective-context evidence (AC6);
- OOM, overflow and restart behaviour against a real runtime (AC4);
- product admission of `LocalExecution`, which is Val's decision.
2 changes: 2 additions & 0 deletions docs/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,8 @@ Desktop CI memory diagnostics landed through #458 after #456’s cold transcript

- **LM-5 effective subagent admission.** A reproduced policy gap accepted enabled native subagent flags despite disabled launch settings. The repair requires both effective values to be false; ten regressions failed before the fix, and 26 focused policy/execution tests now pass, including refusal before a native thread opens. A configuration-only CLI 0.166.0 probe confirms the explicit false report shape. Build and full unit validation passed (2,254 passes, 26 skips); independent review found no blocking findings. It landed through #448 (`ba8f289`) on 2026-10-30, after v0.4.0, and is in the v0.4.1 candidate; native qualification stays separate. [Evidence](reviews/2026-10-30-lm5-subagent-policy.md).

- **LM-5 local-only rule at each auxiliary chokepoint (#380).** `localOnly(thread)` is now enforced where each auxiliary call is made, not only by its callers. A Jev question naming a local thread gets no connection before any key is resolved; credit fallback, thread naming and unattended (routine) turns refuse a local thread, and the routine turn is not rerouted. One regression calls each chokepoint directly, with cloud controls; disabling each of the five guards made it fail. Product admission of `LocalExecution`, the native offline canary, runtime context evidence and real-runtime failure handling stay open. Delivered through #461. [Evidence](reviews/2026-10-30-lm5-local-only-chokepoints.md).

- **v0.4.0 post-publication acceptance and Companion 0.4.0 (9).** Desktop publication is complete. v0.3.0's two post-publication gates (the banner upgrade of the release Mac and the Companion 0.3.0 (8) upload) are superseded: the installed Mac reports signed 0.4.0, and Companion build 9 replaces build 8. The v0.3.0-to-v0.4.0 background upgrade remains unverified: the installed Mac already reports signed 0.4.0 and was left unchanged. Companion build 9 has not been delivered by this release session; its TestFlight upload and physical-device checks remain separate. Human accessibility, real-provider and native-platform account/session checks retain their existing gates. [Publication evidence and limits](reviews/2026-10-24-v0.4.0-release-review.md#published--2026-10-30-utc).

- **Local execution through Codex, in main only and not yet admitted** (LM-3 [#379](https://github.com/TypeSafeAI/modex/issues/379),
Expand Down
Loading