Some checks failed
Tests / PR mergeability (push) Successful in 18s
Tests / Base branch health (push) Successful in 9s
Tests / Detect test scope (push) Successful in 16s
Tests / lint-tests (push) Failing after 1m43s
Tests / plugin-validate (push) Successful in 58s
Tests / test (ubuntu-latest, 24, shard 1/3) (push) Failing after 19s
Tests / test (ubuntu-latest, 24, shard 2/3) (push) Failing after 20s
Tests / test (ubuntu-latest, 24, shard 3/3) (push) Failing after 20s
Tests / test (ubuntu-latest, 24) (push) Failing after 18s
Tests / test (inert CI) (push) Has been skipped
Tests / QA loop walk (smell ratchet) (push) Failing after 19s
Tests / Coverage gate (merged shards) (push) Has been skipped
Tests / Publish emitted-baseline artifact (push) Has been skipped
Duplicate auto-close sweep / sweep (push) Successful in 19s
CI timeout budget report / report (push) Failing after 14s
Close Draft PRs (sweep) / Sweep open draft PRs (push) Successful in 9s
Dismiss Unauthorized PR Approvals / dismiss-unauthorized-approval (push) Successful in 9s
Tests / conformance test (macos-latest, 24) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 1/3) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 2/3) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 3/3) (push) Has been cancelled
Tests / Required tests (push) Has been cancelled
267 lines
10 KiB
JavaScript
267 lines
10 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Stale-bake guard for static-frontmatter runtimes (#1688, follow-up to #1650).
|
|
*
|
|
* Runtimes `codex` and `opencode` bake the resolved
|
|
* model ID into each agent's static config at install time (bin/install.js
|
|
* ~5667-5767 for codex, ~10008-10026 for opencode). Their task/spawn_agent interfaces do not accept
|
|
* an inline `model` parameter, so editing `model_overrides` in
|
|
* `.planning/config.json` or `~/.msd/defaults.json` has NO effect until the
|
|
* user re-runs `msd install <runtime>` (or `msd update`). The failure is
|
|
* silent — the sub-agent just uses the prior base model. This module detects
|
|
* that staleness at workflow entry and emits a single stderr warning.
|
|
*
|
|
* Design: pure decision + formatter (testable, no I/O) backed by fs probes
|
|
* that swallow every error (the guard must never break the CLI). Dedup'd per
|
|
* (runtime, cwd) within a process so a single `msd-tools init *` invocation
|
|
* warns at most once even though multiple agents resolve models underneath.
|
|
*/
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
|
|
/**
|
|
* Runtimes whose agent config is static frontmatter/TOML baked at install time.
|
|
* MUST stay in sync with the bake paths in bin/install.js. The parity test in
|
|
* tests/stale-bake-guard.test.cjs asserts this matches the runtimes that
|
|
* actually emit a baked model: line id #2256 (opencode) and #49/#2256 (codex).
|
|
*/
|
|
const STATIC_FRONTMATTER_RUNTIMES = Object.freeze(['codex', 'opencode']);
|
|
|
|
/** Per-runtime `msd install` flag, for the remediation hint in the warning. */
|
|
const INSTALL_FLAG_BY_RUNTIME = Object.freeze({
|
|
codex: '--codex',
|
|
opencode: '--opencode',
|
|
});
|
|
|
|
const _warnedKeys = new Set();
|
|
|
|
/**
|
|
* Pure: decide whether a stale-bake condition exists.
|
|
*
|
|
* Returns `{ stale: true, deltaMs }` when `configMtimeMs` is strictly newer
|
|
* than `agentMtimeMs` on a static-frontmatter runtime. Returns `null` when the
|
|
* guard does not apply (claude / other spawn-time runtime, missing or
|
|
* non-finite mtimes, or agents already at least as new as config).
|
|
*/
|
|
function detectStaleBake({ runtime, configMtimeMs, agentMtimeMs }) {
|
|
if (!runtime || !STATIC_FRONTMATTER_RUNTIMES.includes(runtime)) return null;
|
|
if (typeof configMtimeMs !== 'number' || typeof agentMtimeMs !== 'number') return null;
|
|
if (!Number.isFinite(configMtimeMs) || !Number.isFinite(agentMtimeMs)) return null;
|
|
if (configMtimeMs <= agentMtimeMs) return null;
|
|
return { stale: true, deltaMs: configMtimeMs - agentMtimeMs };
|
|
}
|
|
|
|
/**
|
|
* Pure: format the warning string. Returns `''` when no warning is warranted
|
|
* (delegates to detectStaleBake so the decision and the message cannot drift).
|
|
*/
|
|
function formatStaleBakeWarning({ runtime, configPath, configMtimeMs, agentMtimeMs }) {
|
|
const signal = detectStaleBake({ runtime, configMtimeMs, agentMtimeMs });
|
|
if (!signal) return '';
|
|
const configDate = new Date(configMtimeMs).toISOString();
|
|
const installFlag = INSTALL_FLAG_BY_RUNTIME[runtime] || `--${runtime}`;
|
|
return [
|
|
`msd: model config in ${configPath} changed since agents were last baked (${configDate}).`,
|
|
` Static-frontmatter runtime '${runtime}' ignores the new model_overrides`,
|
|
` until you re-run: msd install ${installFlag}`,
|
|
` (or 'msd update')`,
|
|
].join('\n');
|
|
}
|
|
|
|
/**
|
|
* Pure: resolve the active runtime id from a parsed config object.
|
|
* Returns the runtime string, or `'claude'` when unset (the spawn-time default
|
|
* for which the guard is a no-op).
|
|
*/
|
|
function resolveRuntimeFromConfig(config) {
|
|
if (config && typeof config === 'object'
|
|
&& typeof config.runtime === 'string' && config.runtime) {
|
|
return config.runtime;
|
|
}
|
|
return 'claude';
|
|
}
|
|
|
|
/**
|
|
* Resolve the install root for a runtime's agent files, honoring the same env
|
|
* vars the installer does (CODEX_HOME, OPENCODE_CONFIG_DIR).
|
|
* Returns the absolute directory or `null` for unsupported runtimes.
|
|
*/
|
|
function resolveAgentDir(runtime, { env = process.env, homedir = os.homedir } = {}) {
|
|
if (runtime === 'opencode') {
|
|
const base = (env.OPENCODE_CONFIG_DIR && String(env.OPENCODE_CONFIG_DIR).trim()) || path.join(homedir(), '.config', 'opencode');
|
|
// #2093 fix: the installer writes agents to `<base>/agents` (plural — see
|
|
// bin/install.js's universal `agentsDest = path.join(targetDir, 'agents')`,
|
|
// verified against a live `--opencode --global` install). The prior
|
|
// singular `agent` never matched the real install output, so this guard's
|
|
// `findOldestAgentMtime` always hit ENOENT and warnIfStaleBake was a
|
|
// silent no-op for opencode in production.
|
|
return path.join(base, 'agents');
|
|
}
|
|
if (runtime === 'codex') {
|
|
const base = (env.CODEX_HOME && String(env.CODEX_HOME).trim()) || path.join(homedir(), '.codex');
|
|
return path.join(base, 'agents');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Find the newest mtime across config sources that exist. Mirrors the
|
|
* up-to-8-levels-up walk in readMsdEffectiveModelOverrides (bin/install.js)
|
|
* and includes the global ~/.msd/defaults.json. Returns
|
|
* `{ mtimeMs, path }` of the newest existing config, or `null` if none exist.
|
|
*
|
|
* `homedir` is injectable so tests can point the global lookup at a fixture
|
|
* dir (otherwise the real ~/.msd/defaults.json on the CI runner leaks in and
|
|
* skews the newest-config calculation — see warnIfStaleBake orchestrator).
|
|
*/
|
|
function findNewestConfigMtime(cwd, { fsStatSync = fs.statSync, homedir = os.homedir } = {}) {
|
|
const candidates = [];
|
|
let probe = path.resolve(cwd || '.');
|
|
for (let i = 0; i < 8; i += 1) {
|
|
candidates.push(path.join(probe, '.planning', 'config.json'));
|
|
const parent = path.dirname(probe);
|
|
if (parent === probe) break;
|
|
probe = parent;
|
|
}
|
|
candidates.push(path.join(homedir(), '.msd', 'defaults.json'));
|
|
|
|
let newest = null;
|
|
for (const p of candidates) {
|
|
try {
|
|
const st = fsStatSync(p);
|
|
if (st && typeof st.mtimeMs === 'number' && Number.isFinite(st.mtimeMs)
|
|
&& (!newest || st.mtimeMs > newest.mtimeMs)) {
|
|
newest = { mtimeMs: st.mtimeMs, path: p };
|
|
}
|
|
} catch {
|
|
// not present / unreadable — skip
|
|
}
|
|
}
|
|
return newest;
|
|
}
|
|
|
|
/**
|
|
* Find the oldest mtime across installed msd-* agent files for the runtime.
|
|
* Returns `{ mtimeMs, dir }` or `null` if the agent dir is absent or holds no
|
|
* msd-* files (e.g. not yet installed, or uninstalled).
|
|
*/
|
|
function findOldestAgentMtime(runtime, { env = process.env, homedir = os.homedir, fsStatSync = fs.statSync, fsReaddirSync = fs.readdirSync } = {}) {
|
|
const dir = resolveAgentDir(runtime, { env, homedir });
|
|
if (!dir) return null;
|
|
let entries;
|
|
try {
|
|
entries = fsReaddirSync(dir, { withFileTypes: true });
|
|
} catch {
|
|
return null; // dir missing — runtime not installed for this user
|
|
}
|
|
let oldest = null;
|
|
for (const entry of entries) {
|
|
if (!entry.isFile()) continue;
|
|
if (!entry.name.startsWith('msd-')) continue;
|
|
const isAgentFile = (runtime === 'opencode' && entry.name.endsWith('.md'))
|
|
|| (runtime === 'codex' && (entry.name.endsWith('.toml') || entry.name.endsWith('.md')));
|
|
if (!isAgentFile) continue;
|
|
try {
|
|
const st = fsStatSync(path.join(dir, entry.name));
|
|
if (st && typeof st.mtimeMs === 'number' && Number.isFinite(st.mtimeMs)
|
|
&& (!oldest || st.mtimeMs < oldest.mtimeMs)) {
|
|
oldest = { mtimeMs: st.mtimeMs, dir };
|
|
}
|
|
} catch {
|
|
// unreadable — skip
|
|
}
|
|
}
|
|
return oldest;
|
|
}
|
|
|
|
/**
|
|
* Orchestrator (side-effecting): probe fs, decide, write warning to stderr.
|
|
*
|
|
* - Silent on claude / other spawn-time runtimes (returns false).
|
|
* - Silent when agents are already at least as new as config.
|
|
* - Silent when config or agent dir is absent (nothing to compare).
|
|
* - Dedup'd per (runtime, cwd): a single process warns at most once per pair,
|
|
* so repeated `resolveModelInternal` calls under one `msd-tools init *` do
|
|
* not repeat the warning.
|
|
* - Swallows every error: a warning helper must never break the CLI.
|
|
*
|
|
* Pass `config` to skip the internal JSON read (caller already loaded it).
|
|
* Returns `true` if a warning was written, `false` otherwise.
|
|
*/
|
|
function warnIfStaleBake(cwd, options = {}) {
|
|
const {
|
|
stderr = process.stderr,
|
|
config = null,
|
|
env = process.env,
|
|
homedir = os.homedir,
|
|
fsStatSync = fs.statSync,
|
|
fsReaddirSync = fs.readdirSync,
|
|
} = options;
|
|
try {
|
|
const resolvedConfig = config || _readRuntimeConfig(cwd, { fsStatSync });
|
|
const runtime = resolveRuntimeFromConfig(resolvedConfig);
|
|
if (!STATIC_FRONTMATTER_RUNTIMES.includes(runtime)) return false;
|
|
|
|
const dedupKey = `${runtime}::${path.resolve(cwd || '.')}`;
|
|
if (_warnedKeys.has(dedupKey)) return false;
|
|
|
|
const newest = findNewestConfigMtime(cwd, { fsStatSync, homedir });
|
|
const oldest = findOldestAgentMtime(runtime, { env, homedir, fsStatSync, fsReaddirSync });
|
|
if (!newest || !oldest) return false;
|
|
|
|
const warning = formatStaleBakeWarning({
|
|
runtime,
|
|
configPath: newest.path,
|
|
configMtimeMs: newest.mtimeMs,
|
|
agentMtimeMs: oldest.mtimeMs,
|
|
});
|
|
if (!warning) return false;
|
|
|
|
stderr.write(warning + '\n');
|
|
_warnedKeys.add(dedupKey);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Best-effort minimal read of `.planning/config.json` for the `runtime` key. */
|
|
function _readRuntimeConfig(cwd, { fsStatSync = fs.statSync } = {}) {
|
|
let probe = path.resolve(cwd || '.');
|
|
for (let i = 0; i < 8; i += 1) {
|
|
const candidate = path.join(probe, '.planning', 'config.json');
|
|
try {
|
|
fsStatSync(candidate);
|
|
const raw = fs.readFileSync(candidate, 'utf8');
|
|
const parsed = JSON.parse(raw);
|
|
if (parsed && typeof parsed === 'object') return parsed;
|
|
return {};
|
|
} catch {
|
|
// not present / unreadable / malformed — walk up
|
|
}
|
|
const parent = path.dirname(probe);
|
|
if (parent === probe) break;
|
|
probe = parent;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
/** Test-only: reset the in-process dedup set between cases. */
|
|
function _resetWarnedForTests() {
|
|
_warnedKeys.clear();
|
|
}
|
|
|
|
module.exports = {
|
|
STATIC_FRONTMATTER_RUNTIMES,
|
|
detectStaleBake,
|
|
formatStaleBakeWarning,
|
|
resolveRuntimeFromConfig,
|
|
resolveAgentDir,
|
|
findNewestConfigMtime,
|
|
findOldestAgentMtime,
|
|
warnIfStaleBake,
|
|
_resetWarnedForTests,
|
|
};
|