diff --git a/changelog.d/1128.fixed.md b/changelog.d/1128.fixed.md new file mode 100644 index 00000000..0fcf1dc3 --- /dev/null +++ b/changelog.d/1128.fixed.md @@ -0,0 +1,16 @@ +- Fixed: the nested `claude -p` the summarizer spawns for a save, an NDC + compression, or a staging consolidation inherited `CLAUDE_CODE_EFFORT_LEVEL` + from the parent environment. Anyone who sets Claude's own effort to `max` in + `~/.claude/settings.json` (the only way to keep it at `max` across sessions) + had every nested Haiku call run at max effort instead of the plugin's own + choice: small saves still finished but cost about five times the output + tokens, and the larger NDC and consolidation calls stopped finishing inside + their 180s timeout at all. Memory kept working but got slower on every save, + then `now.md` stopped rotating once NDC itself always timed out, grew past + `session_start_max_bytes`, and SessionStart silently stopped injecting it -- + with nothing in the logs explaining why. `CLAUDE_CODE_EFFORT_LEVEL` is now + removed from the nested call's environment the same way the plugin already + removes the parent session's own identity variables, and restored afterward. + `CLAUDE_EFFORT` is deliberately left alone: it is not a Claude Code session + variable, and unsetting only `CLAUDE_CODE_EFFORT_LEVEL` was enough to fix + the measured timeouts (#1128). diff --git a/docs/configuration.md b/docs/configuration.md index c138653a..94d25631 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -110,10 +110,14 @@ does not pass as that session back afterwards: `CLAUDECODE`, `CLAUDE_JOB_DIR`, `CLAUDE_PROJECT_DIR`, `CLAUDE_CODE_SESSION_ID`, `CLAUDE_CODE_ENTRYPOINT`, `CLAUDE_CODE_CHILD_SESSION`, `CLAUDE_CODE_SESSION_ATTENDED`, `CLAUDE_CODE_EXECPATH`, `CLAUDE_CODE_MESSAGING_SOCKET`, -`CLAUDE_CODE_SSE_PORT` -- the reason for each is a comment beside the code in -`pipeline/summarizer.py`. `CLAUDE_CODE_MESSAGING_TOKEN`, the handshake paired with the messaging -socket, is not on the list and reaches the nested call: with the socket removed, the nested -call has no channel to present it on. Your login credential is never on that list +`CLAUDE_CODE_SSE_PORT`, `CLAUDE_CODE_EFFORT_LEVEL` -- the reason for each is a comment beside +the code in `pipeline/summarizer.py`. `CLAUDE_CODE_MESSAGING_TOKEN`, the handshake paired with +the messaging socket, is not on the list and reaches the nested call: with the socket removed, +the nested call has no channel to present it on. `CLAUDE_EFFORT` is not on the list either and +also reaches the nested call -- only `CLAUDE_CODE_EFFORT_LEVEL` outranks `--effort` and every +settings-file source, so only it needs stripping +([#1128](https://github.com/Digital-Process-Tools/claude-remember/issues/1128)). Your login +credential is never on that list ([#131](https://github.com/Digital-Process-Tools/claude-remember/issues/131)); provider settings such as Bedrock selection are not either, so they reach the nested call ([#316](https://github.com/Digital-Process-Tools/claude-remember/issues/316)). To keep any diff --git a/pipeline/summarizer.py b/pipeline/summarizer.py index cc7d2391..bd4c7f84 100644 --- a/pipeline/summarizer.py +++ b/pipeline/summarizer.py @@ -382,6 +382,15 @@ def _without_session_env(): saved_messaging_socket = os.environ.pop("CLAUDE_CODE_MESSAGING_SOCKET", None) # The parent's IDE connection; the child would attach to the user's IDE. saved_sse_port = os.environ.pop("CLAUDE_CODE_SSE_PORT", None) + # #1128: outranks --effort and every settings-file source, so a parent + # session that pins "max" (the only way to keep max effort across + # sessions, since effortLevel stops at xhigh) sends Haiku into max-effort + # thinking on a summarization task -- NDC and consolidation then blow + # their 180s call timeout. CLAUDE_EFFORT is deliberately NOT on this + # list: the reporter measured that unsetting only this one variable fixed + # it, and CLAUDE_EFFORT is not a Claude Code session variable the child + # would otherwise look like a resumable part of. + saved_effort_level = os.environ.pop("CLAUDE_CODE_EFFORT_LEVEL", None) try: yield finally: @@ -405,6 +414,8 @@ def _without_session_env(): os.environ["CLAUDE_CODE_MESSAGING_SOCKET"] = saved_messaging_socket if saved_sse_port is not None: os.environ["CLAUDE_CODE_SSE_PORT"] = saved_sse_port + if saved_effort_level is not None: + os.environ["CLAUDE_CODE_EFFORT_LEVEL"] = saved_effort_level @contextlib.contextmanager diff --git a/tests/test_summarizer_env_898.py b/tests/test_summarizer_env_898.py index 902918d5..652b94ed 100644 --- a/tests/test_summarizer_env_898.py +++ b/tests/test_summarizer_env_898.py @@ -55,7 +55,9 @@ # #204 already covered, and the IDE port (reasoned: set when an IDE is # attached, not observed in that dump). Same names, same order as round 16's # shipped `haiku.strip_session_env`, minus the messaging handshake (round -# 18). A future Claude Code session variable that is not here reaches the +# 18), plus CLAUDE_CODE_EFFORT_LEVEL (#1128 -- not from round 16's dump or +# `strip_session_env`; it outranks --effort and every settings-file source). +# A future Claude Code session variable that is not here reaches the # summarizer until a release adds it to `_without_session_env` AND here. EXPECTED_SESSION_NAMES = ( "CLAUDECODE", @@ -68,7 +70,15 @@ "CLAUDE_CODE_EXECPATH", "CLAUDE_CODE_MESSAGING_SOCKET", "CLAUDE_CODE_SSE_PORT", + # #1128: outranks --effort and every settings-file source; a parent that + # pins "max" for its own sessions sends Haiku into max-effort thinking on + # a summarization task, which blows the 180s call timeout. + "CLAUDE_CODE_EFFORT_LEVEL", ) +# #1128: the *other* effort variable. The reporter measured that unsetting only +# CLAUDE_CODE_EFFORT_LEVEL fixed the timeouts and left this one alone -- it is +# not a Claude Code session variable, so it is inherited like any other. +OTHER_EFFORT_VAR = "CLAUDE_EFFORT" # Round 18: inherited, no longer on the list. MESSAGING_HANDSHAKE = "CLAUDE_CODE_MESSAGING_TOKEN" @@ -265,6 +275,25 @@ def test_child_inherits_an_arbitrary_variable(mock_run, monkeypatch, isolated_co assert seen[-1]["env"].get("REMEMBER_TEST_ARBITRARY_898") == "kept" +@patch("pipeline.summarizer.subprocess.run") +def test_effort_level_is_stripped_other_effort_var_is_inherited( + mock_run, monkeypatch, isolated_config): + """#1128: CLAUDE_CODE_EFFORT_LEVEL outranks --effort and every settings + file, so a parent that pins "max" sends Haiku into max-effort thinking on + a summarization task and blows the call's own timeout. CLAUDE_EFFORT is + the positive control: the reporter measured that unsetting only the + _LEVEL variable fixed it, and it is not a Claude Code session variable, + so it must reach the child like any other.""" + monkeypatch.setenv("CLAUDE_CODE_EFFORT_LEVEL", "max") + monkeypatch.setenv(OTHER_EFFORT_VAR, "max") + seen = _capture(mock_run) + call_haiku("p") + env = seen[-1]["env"] + assert "CLAUDE_CODE_EFFORT_LEVEL" not in env + assert env.get(OTHER_EFFORT_VAR) == "max", "positive control: not everything is stripped" + assert os.environ.get("CLAUDE_CODE_EFFORT_LEVEL") == "max", "restored after the spawn" + + @pytest.mark.parametrize("name", EXPECTED_SESSION_NAMES) @patch("pipeline.summarizer.subprocess.run") def test_each_listed_session_variable_is_removed(mock_run, name, monkeypatch, isolated_config):