mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
Merge #767 fix(custom-model): give a session its own env values back when the selection goes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
9751f51bdc
17 files changed
+537
-62
No files matched your search
@@ -202,7 +202,7 @@ import {
|
||||
} from '../../docker-hosts.js';
|
||||
import { resolveCaseRepository, resolveManagedWorktree } from '../../git-worktrees.js';
|
||||
import { LRUMap } from '../../utils/lru-map.js';
|
||||
import { describeUnknownPath, probePathKind } from '../../utils/index.js';
|
||||
import { describeUnknownPath, probePathKind, type PathProbeOptions } from '../../utils/index.js';
|
||||
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
|
||||
import { scanOmpSessionsHistory } from '../../omp-transcript.js';
|
||||
import { scanCodexSessionsHistory, codexThreadBySessionId } from '../../codex-transcript.js';
|
||||
@@ -244,18 +244,20 @@ function landedInGroup(layout: TabLayout | undefined, sessionId: string): boolea
|
||||
* Probe the session's own home with `probePathKind(..., { pastCap: true })` first: on
|
||||
* `'unknown'` (a dead mount that never answered) hand the apply a lookup that hides that
|
||||
* one env var, so linking falls through to the server's own environment and the unresponsive
|
||||
* path is never touched. Any other entry's lookup passes through untouched.
|
||||
* path is never touched. Any other entry's lookup passes through untouched. Boot recovery
|
||||
* passes `probeOptions` of `{}`: it walks every saved session, so it keeps the bulk cap.
|
||||
*/
|
||||
export async function guardSessionConfigDirHome(
|
||||
entry: Pick<CliEntry, 'capabilities'>,
|
||||
baseLookup: (envVar: string) => string | undefined
|
||||
baseLookup: (envVar: string) => string | undefined,
|
||||
probeOptions: PathProbeOptions = { pastCap: true }
|
||||
): Promise<(envVar: string) => string | undefined> {
|
||||
const recipe = entry.capabilities.customModelInjection;
|
||||
if (recipe?.kind !== 'configDir' || !recipe.linkFromRealHome?.length) return baseLookup;
|
||||
const dirEnvVar = recipe.dirEnvVar;
|
||||
const supplied = baseLookup(dirEnvVar)?.trim();
|
||||
if (!supplied || !isAbsolute(supplied)) return baseLookup;
|
||||
if ((await probePathKind(supplied, { pastCap: true })) === 'unknown') {
|
||||
if ((await probePathKind(supplied, probeOptions)) === 'unknown') {
|
||||
console.warn(
|
||||
`[custom-model] ${dirEnvVar} (${supplied}) did not answer; linking from the server's own home instead`
|
||||
);
|
||||
@@ -1675,7 +1677,7 @@ export function registerSessionRoutes(
|
||||
session.id,
|
||||
contextLength,
|
||||
session.workingDir,
|
||||
await guardSessionConfigDirHome(entry, (envVar) => session.envOverrideValue(envVar))
|
||||
await guardSessionConfigDirHome(entry, (envVar) => session.ownEnvValue(envVar))
|
||||
);
|
||||
if (!applied) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${session.mode} has no known custom-model mechanism`);
|
||||
@@ -1689,6 +1691,7 @@ export function registerSessionRoutes(
|
||||
envKeys: applied.envKeys,
|
||||
configDir: applied.configDir,
|
||||
launchModel: applied.launchModel,
|
||||
homeEnvVar: applied.homeEnvVar,
|
||||
},
|
||||
applied.envOverrides
|
||||
);
|
||||
@@ -4467,12 +4470,12 @@ export function registerSessionRoutes(
|
||||
// unknown endpoint, a model id the CLI's argv pattern can't carry) rather than trusting
|
||||
// a lighter version of them, since this is the same server-side authority reached a
|
||||
// different way, not a separate, less-checked path.
|
||||
let qsCustomModelEnvOverrides = qsGatedEnvOverrides;
|
||||
// Only the INJECTED keys (never the caller's envOverrides merged in) — this is what
|
||||
// setCustomModel() bookkeeping must be given below. The Session constructor already
|
||||
// applies qsCustomModelEnvOverrides (the full merged set) directly; re-merging that
|
||||
// full set into setCustomModel() would put CLAUDE_CODE_EFFORT_LEVEL back after the
|
||||
// constructor stripped it (see setCustomModel()'s own doc comment in session.ts).
|
||||
//
|
||||
// Only the INJECTED keys (never the caller's envOverrides merged in): setCustomModel()
|
||||
// merges these over what the Session constructor took, which is how it learns which of
|
||||
// the caller's values the selection replaced. Handing the constructor the merged set
|
||||
// would hide them, and giving setCustomModel() that set would put
|
||||
// CLAUDE_CODE_EFFORT_LEVEL back after the constructor stripped it.
|
||||
let qsCustomModelAppliedEnvOverrides: Record<string, string> | undefined;
|
||||
let qsCustomModelLaunchModel: string | undefined;
|
||||
let qsCustomModelSessionId: string | undefined;
|
||||
@@ -4485,6 +4488,7 @@ export function registerSessionRoutes(
|
||||
envKeys: string[];
|
||||
configDir?: string;
|
||||
launchModel?: string;
|
||||
homeEnvVar?: string;
|
||||
}
|
||||
| undefined;
|
||||
if (customModel) {
|
||||
@@ -4588,7 +4592,6 @@ export function registerSessionRoutes(
|
||||
if (!cmApplied) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
|
||||
}
|
||||
qsCustomModelEnvOverrides = { ...qsGatedEnvOverrides, ...cmApplied.envOverrides };
|
||||
qsCustomModelAppliedEnvOverrides = cmApplied.envOverrides;
|
||||
qsCustomModelLaunchModel = cmApplied.launchModel;
|
||||
qsCustomModelBookkeeping = {
|
||||
@@ -4598,6 +4601,7 @@ export function registerSessionRoutes(
|
||||
envKeys: cmApplied.envKeys,
|
||||
configDir: cmApplied.configDir,
|
||||
launchModel: cmApplied.launchModel,
|
||||
homeEnvVar: cmApplied.homeEnvVar,
|
||||
};
|
||||
|
||||
// Actually kick off llama-swap's load now — see the dedicated apply route's own
|
||||
@@ -4630,7 +4634,7 @@ export function registerSessionRoutes(
|
||||
copilotConfig: mode === 'copilot' ? qsGatedCopilotConfig : undefined,
|
||||
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
|
||||
ompConfig: qsResolvedOmpConfig,
|
||||
envOverrides: qsCustomModelEnvOverrides,
|
||||
envOverrides: qsGatedEnvOverrides,
|
||||
effort,
|
||||
advisorModel,
|
||||
remote,
|
||||
@@ -4659,10 +4663,10 @@ export function registerSessionRoutes(
|
||||
const session = new Session(qsSessionOptions);
|
||||
|
||||
// Records the selection for session.customModel/getCustomModelForPersist() and future
|
||||
// clear/switch calls — the actual env vars and launch-model config are already part of
|
||||
// the launch above (constructor envOverrides, piConfig/grokConfig/ompConfig.model), so
|
||||
// this is bookkeeping only, never a restart: setCustomModel() is synchronous state, no
|
||||
// tmux IO of its own (see its own doc comment in session.ts).
|
||||
// clear/switch calls, and merges the injected env, which the launch below reads along
|
||||
// with the launch-model config set above (piConfig/grokConfig/ompConfig.model). Nothing
|
||||
// has started yet, so this is never a restart: setCustomModel() is synchronous state,
|
||||
// no tmux IO of its own (see its own doc comment in session.ts).
|
||||
if (qsCustomModelBookkeeping) {
|
||||
session.setCustomModel(qsCustomModelBookkeeping, qsCustomModelAppliedEnvOverrides);
|
||||
}
|
||||
|
||||
+25
-12
@@ -104,6 +104,7 @@ import { RunSummaryTracker } from '../run-summary.js';
|
||||
import { PlanOrchestrator } from '../plan-orchestrator.js';
|
||||
import { OrchestratorLoop } from '../orchestrator-loop.js';
|
||||
import { getLifecycleLog } from '../session-lifecycle-log.js';
|
||||
import { clampEnvOverridesForOwner } from '../session-env-clamp.js';
|
||||
import {
|
||||
applyWorkspaceHooks,
|
||||
pruneAgentSessionPreambles,
|
||||
@@ -135,6 +136,7 @@ import { fetchWorkspaceOnce } from '../git-fetch.js';
|
||||
import { GitFetchScheduler } from '../git-fetch-scheduler.js';
|
||||
import { probePathKind } from '../utils/bounded-path-probe.js';
|
||||
import { defaultDockerWorkspaces } from './routes/git-status-routes.js';
|
||||
import { guardSessionConfigDirHome } from './routes/session-routes.js';
|
||||
import { composerIsEmpty, plainFrame } from '../session-submit-verifier.js';
|
||||
import { detectUsageLimitPause } from '../usage-limit-patterns.js';
|
||||
import { EditHistorySync } from './edit-history-sync.js';
|
||||
@@ -1519,7 +1521,8 @@ export class WebServer extends EventEmitter {
|
||||
*/
|
||||
private async _rebuildCustomModelEnv(
|
||||
session: Session,
|
||||
saved: CustomModelBookkeeping
|
||||
saved: CustomModelBookkeeping,
|
||||
ownEnv: Record<string, string> = {}
|
||||
): Promise<AppliedCustomModel | undefined> {
|
||||
const entry = getCli(session.mode);
|
||||
if (!entry) return undefined;
|
||||
@@ -1531,19 +1534,21 @@ export class WebServer extends EventEmitter {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
// Boot recovery omits both `workingDir` (no reason to re-answer a trust dialog that
|
||||
// already fired once) and the session-env real-home lookup: the injected VALUES are
|
||||
// never persisted, so after a real restart the session can no longer carry its
|
||||
// allowlisted `DSH_HOME` either (`getEnvOverridesForPersist()` keeps only
|
||||
// `CLAUDE_CODE_*`/`CLAUDE_CONFIG_DIR`): the lookup could only ever hit on the first
|
||||
// apply in-process. Real-home resolution therefore falls back to the SERVER's
|
||||
// Boot recovery omits `workingDir` (no reason to re-answer a trust dialog that already
|
||||
// fired once). The real-home lookup reads the record's own values, since the session's
|
||||
// overrides hold what the previous run injected. `linkRealHomeEntry()` keeps what is
|
||||
// there, so this only decides an entry that is missing, which an own home skips when it
|
||||
// does not hold one. With no own home, or one that does not answer (probed under the
|
||||
// bulk cap, this walks every saved session), a missing entry links from the SERVER's
|
||||
// `DSH_HOME` or the recipe's `realHomeFallback` (~/.dsh).
|
||||
return applyCustomModelInjection(
|
||||
entry,
|
||||
endpoint,
|
||||
saved.modelId,
|
||||
session.id,
|
||||
resolveCustomModelContextLength(endpoint, saved.modelId)
|
||||
resolveCustomModelContextLength(endpoint, saved.modelId),
|
||||
undefined,
|
||||
await guardSessionConfigDirHome(entry, (envVar) => ownEnv[envVar], {})
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[WebServer] Failed to rebuild custom-model env on recovery:', err);
|
||||
@@ -1559,18 +1564,25 @@ export class WebServer extends EventEmitter {
|
||||
* while the new recipe writes one, and clearing it later could then never delete the
|
||||
* generated dir. `envKeys` is the UNION of saved and applied keys: the tmux session may
|
||||
* still carry `setenv`s from both, and a clear has to unset every one.
|
||||
*
|
||||
* `ownEnv` is always passed, empty for a record saved before it existed: `_envOverrides`
|
||||
* holds what the previous run injected, and only this record says what was the session's
|
||||
* own. It meets the owner's grant again, like every other env override rebuilt from disk.
|
||||
*/
|
||||
private async _restoreCustomModel(session: Session, saved: CustomModelBookkeeping): Promise<void> {
|
||||
const applied = await this._rebuildCustomModelEnv(session, saved);
|
||||
const ownEnv = (await clampEnvOverridesForOwner(session.owner, saved.ownEnv)) ?? {};
|
||||
const applied = await this._rebuildCustomModelEnv(session, saved, ownEnv);
|
||||
if (!applied) {
|
||||
// Endpoint gone or CLI unregistered: restore the bookkeeping exactly as saved, which
|
||||
// is what a later clear needs.
|
||||
session.setCustomModel(saved);
|
||||
session.setCustomModel({ ...saved, ownEnv });
|
||||
return;
|
||||
}
|
||||
session.setCustomModel(
|
||||
{
|
||||
...saved,
|
||||
ownEnv,
|
||||
homeEnvVar: applied.homeEnvVar ?? saved.homeEnvVar,
|
||||
// Prefer the current recipe's dir; keep the saved one when the recipe writes none,
|
||||
// so a clear can still delete a directory an older recipe created.
|
||||
configDir: applied.configDir ?? saved.configDir,
|
||||
@@ -1590,8 +1602,9 @@ export class WebServer extends EventEmitter {
|
||||
// __attachmentHistory keeps the private (externalPath-bearing) history on disk,
|
||||
// separate from the sanitized public attachmentHistory in toState().
|
||||
const attachmentHistory = session.getAttachmentHistoryForPersist();
|
||||
// __customModel keeps the selection's bookkeeping (injected env KEYS, config dir,
|
||||
// launch model; never the values) so recovery can restore and later clear it.
|
||||
// __customModel keeps the selection's bookkeeping (injected env KEYS, config dir, launch
|
||||
// model, the session's own home path; never an injected value or any other own value)
|
||||
// so recovery can restore and later clear it.
|
||||
const customModel = session.getCustomModelForPersist();
|
||||
const state = {
|
||||
...base,
|
||||
|
||||
Reference in new issue
Block a user