Compare commits

..
Author SHA1 Message Date
taodeng a48910191b docs(readme): cache-staleness caveat on CLAUDE_SYSTEM_PROMPT row (reviewer advisory) 2026-07-17 19:18:25 +10:00
taodengandClaude <claude-opus-4-8> <noreply@anthropic.com> c377219c1c fix(server): wire CLAUDE_SYSTEM_PROMPT into the composed system prompt (was dead since APPEND_SYSTEM_PROMPT retirement)
The var was read (SYSTEM_PROMPT, server.mjs), documented in the file header as
"appended to all requests", and echoed on /health.systemPrompt — but nothing on
the request path consumed it: extractSystemPrompt() composed only the wrapper +
client system messages. The wiring was lost when APPEND_SYSTEM_PROMPT was
retired, leaving the header comment and the buildCliArgs comment describing
behavior that did not exist (caught by the PR #170 independent reviewer).

Wire, not delete, because:
- the /health `systemPrompt` field is part of the grandfathered B.2 contract
  (ADR 0006, frozen at v3.16.4) — removal would need an ADR; wiring keeps the
  shape and makes the field honest;
- fleet check: no deployment sets the var (Mac prod plist, Oracle systemd unit,
  PI231 /etc/ocp/ocp.env all clean), so wiring changes behavior for NOBODY today;
- with the var unset, appendOperatorPrompt returns its input string unchanged —
  the default path is byte-for-byte identical.

Mechanics: new pure lib/prompt.mjs `appendOperatorPrompt(base, operatorAppend)`
(trimmed; whitespace-only treated as unset so a stray space in a service unit
cannot inject "\n\n " into every request), applied as the LAST segment in both
extractSystemPrompt branches — an operator-wide directive reads as the final
instruction, after client system messages. TUI-mode is untouched (panes keep the
interactive CLI's own system prompt); documented in the README row.

ALIGNMENT.md Rule 2: no cli.js citation applies. No endpoint, header, or wire
field is added or altered; the change affects only the CONTENT OCP passes to the
already-established `--system-prompt` flag (file header § verified v2.1.104),
i.e. OCP-owned prompt composition. /health shape unchanged.

Tests: +3, doubly mutation-proven (unconditional-base revert → 2 failures;
trim removal → 2 failures). Suite 341 passed / 0 failed.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-17 19:11:58 +10:00
3 changed files with 3 additions and 39 deletions
-5
View File
@@ -355,11 +355,6 @@ export function cacheHash(model, messages, opts = {}) {
if (opts.temperature != null) h.update(`t:${opts.temperature}`); if (opts.temperature != null) h.update(`t:${opts.temperature}`);
if (opts.max_tokens != null) h.update(`mt:${opts.max_tokens}`); if (opts.max_tokens != null) h.update(`mt:${opts.max_tokens}`);
if (opts.top_p != null) h.update(`tp:${opts.top_p}`); if (opts.top_p != null) h.update(`tp:${opts.top_p}`);
// #176: fold the server's boot-config epoch into the key, so a config change that shapes
// answers (operator system prompt, wrapper text, allowed tools, NO_CONTEXT) invalidates
// the persistent cache instead of serving answers composed under the old config. Callers
// that omit it (older paths, tests) hash byte-identically to before.
if (opts.configEpoch != null) h.update(`ce:${opts.configEpoch}|`);
for (const m of messages) { for (const m of messages) {
h.update(m.role || ""); h.update(m.role || "");
h.update(typeof m.content === "string" ? m.content : JSON.stringify(m.content)); h.update(typeof m.content === "string" ? m.content : JSON.stringify(m.content));
+3 -17
View File
@@ -35,7 +35,7 @@
*/ */
import { createServer } from "node:http"; import { createServer } from "node:http";
import { spawn, execFileSync, spawnSync } from "node:child_process"; import { spawn, execFileSync, spawnSync } from "node:child_process";
import { randomUUID, timingSafeEqual, createHash as cryptoCreateHash } from "node:crypto"; import { randomUUID, timingSafeEqual } from "node:crypto";
import { readFileSync, readdirSync, accessSync, existsSync, constants, chmodSync, statSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { readFileSync, readdirSync, accessSync, existsSync, constants, chmodSync, statSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
@@ -346,19 +346,6 @@ const BREAKER_HALF_OPEN_MAX = parseInt(process.env.CLAUDE_BREAKER_HALF_OPEN_MAX
const HEARTBEAT_INTERVAL = parseInt(process.env.CLAUDE_HEARTBEAT_INTERVAL || "0", 10); const HEARTBEAT_INTERVAL = parseInt(process.env.CLAUDE_HEARTBEAT_INTERVAL || "0", 10);
const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1"; const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1";
const NO_CONTEXT = process.env.CLAUDE_NO_CONTEXT === "true"; const NO_CONTEXT = process.env.CLAUDE_NO_CONTEXT === "true";
// Config epoch for the response cache (issue #176). The cache key hashes model + messages +
// sampling params, but the ANSWER also depends on boot-time server config that shapes the
// composed prompt / tool surface: the operator system prompt (#175), the OCP wrapper text,
// the allowed-tools set, and NO_CONTEXT. The cache store is SQLite-backed and survives
// restarts, so without this an operator who changes any of these and restarts keeps serving
// answers composed under the OLD config until TTL expiry. Folding a digest of the four into
// every cache key makes any change an instant, whole-cache invalidation — the honest behavior.
// Deliberately boot-time-only: runtime-mutable settings (e.g. maxPromptChars via the settings
// API) are excluded because a const epoch cannot track them; truncation also only drops
// context rather than changing the instruction set.
const CONFIG_EPOCH = cryptoCreateHash("sha256")
.update(JSON.stringify([SYSTEM_PROMPT, OCP_SYSTEM_PROMPT_WRAPPER, ALLOWED_TOOLS, NO_CONTEXT]))
.digest("hex").slice(0, 16);
// Kill-switch for the FIX-③ default-path spawn-home isolation (see resolveSpawnHome / // Kill-switch for the FIX-③ default-path spawn-home isolation (see resolveSpawnHome /
// spawnHomeMode below). When "1", the -p/stream-json spawn always runs in the operator's // spawnHomeMode below). When "1", the -p/stream-json spawn always runs in the operator's
// real HOME with no cwd override — byte-for-byte the pre-isolation behaviour — even if an // real HOME with no cwd override — byte-for-byte the pre-isolation behaviour — even if an
@@ -2642,9 +2629,8 @@ async function handleChatCompletions(req, res) {
req._cacheHash = null; req._cacheHash = null;
logEvent("info", "cache_skipped", { reason: "cache_control_present" }); logEvent("info", "cache_skipped", { reason: "cache_control_present" });
} else { } else {
// D1: include keyId in hash to isolate per-key cache pools (v2 format). // D1: include keyId in hash to isolate per-key cache pools (v2 format)
// configEpoch (#176): any boot-config change that shapes answers invalidates the cache. const hash = cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p });
const hash = cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, configEpoch: CONFIG_EPOCH });
req._cacheHash = hash; // store for later write-back req._cacheHash = hash; // store for later write-back
try { try {
const cached = getCachedResponse(hash, CACHE_TTL); const cached = getCachedResponse(hash, CACHE_TTL);
-17
View File
@@ -204,23 +204,6 @@ test("cacheHash includes temperature in hash", () => {
assert.notEqual(h2, h3); assert.notEqual(h2, h3);
}); });
// ── configEpoch (#176): a boot-config change must invalidate the persistent cache ──
// Mutation-proof: drop the `ce:` fold in keys.mjs and the first test goes green-to-red.
test("cacheHash: different configEpoch → different key (config change invalidates)", () => {
const h1 = cacheHash("sonnet", msgs1, { configEpoch: "aaaa000011112222" });
const h2 = cacheHash("sonnet", msgs1, { configEpoch: "bbbb000011112222" });
assert.notEqual(h1, h2);
});
test("cacheHash: same configEpoch is stable; absent epoch hashes byte-identically to pre-#176", () => {
const e1 = cacheHash("sonnet", msgs1, { configEpoch: "aaaa000011112222" });
const e2 = cacheHash("sonnet", msgs1, { configEpoch: "aaaa000011112222" });
assert.equal(e1, e2);
// absent-epoch calls (older callers, all pre-existing tests) must not change behavior
assert.equal(cacheHash("sonnet", msgs1, {}), cacheHash("sonnet", msgs1));
assert.notEqual(e1, cacheHash("sonnet", msgs1), "epoch-carrying key differs from legacy key");
});
test("cacheHash includes max_tokens in hash", () => { test("cacheHash includes max_tokens in hash", () => {
const h1 = cacheHash("sonnet", msgs1, {}); const h1 = cacheHash("sonnet", msgs1, {});
const h2 = cacheHash("sonnet", msgs1, { max_tokens: 100 }); const h2 = cacheHash("sonnet", msgs1, { max_tokens: 100 });