Files
chemenu/tools/chemenu/session.py
T
torben e4e2332e01
CI / verify (push) Successful in 52s
Release / release (push) Successful in 36s
session: Harness-Session-Variable schliesst die Luecke im Session-Id-Fallback (Telemetrie-Join, Iteration-Budget-Gate); SIGPIPE-Nebenbefund im Emitter behoben
Files changed:
- CHANGES.md
- EVALS.md
- INSTALL.md
- VERSION
- instructions/session-setup.md
- tools/CONTRACT.md
- tools/chemenu/cli.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/run_budget.py
- tools/chemenu/session.py
- tools/chemenu/telemetry/writer.py
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/test_cli.py
- tools/chemenu/tests/test_run_budget.py
- tools/chemenu/tests/test_telemetry_emit.py
2026-09-16 19:18:00 +02:00

93 lines
3.6 KiB
Python

"""Session identity, shared by the budget gate and the trace emitter.
One definition, because the two must agree: if telemetry grouped events
differently from the way the budget counts calls, a trace could not be read
against the gate that refused it.
Three-step fallback chain, in order:
1. `WIKITOOL_SESSION_ID`, if the caller set one explicitly. Skills set it so a
session is scoped to a task rather than to a terminal window (see
instructions/session-setup.md).
2. A harness's own session variable, from `HARNESS_ENV_VARS` below - checked
only when nothing set the variable above.
3. `os.getppid()` - the parent process of this CLI invocation. On a harness
that runs every tool call in a freshly initialised shell (Claude Code's
Bash tool does), this is a new "session" per call and neither the
iteration-budget gate's ceiling nor its loop-breaker can ever trip - see
Gitea #110, which measured a 33-call run splitting into 21 telemetry
buckets under this fallback alone.
Step 2 is what closes that gap without asking every skill to `export` a
variable a harness already re-derives per call: `CLAUDE_CODE_SESSION_ID` is
stable across a Claude Code session's tool calls (verified 2026-09-16,
against a live session, across separate Bash invocations - the shell's own
PID changed on every call, this variable did not) and is **exactly** the id
the `UserPromptSubmit` hook writes into a trace's `session.start` and
`prompt.submitted` events. Using it unmodified as the budget/telemetry key -
no prefix, no rewriting - is what lets the hook's events and this module's
events land in the same bucket.
`HARNESS_ENV_VARS` only ever grows by a verified entry: a variable a real
session was observed setting, confirmed to be the same id a harness's own
hooks use elsewhere in a trace. A guessed name that happens to exist and
means something else would be worse than the `getppid()` fallback it would
replace - it would look like a fix and quietly mis-key a session instead.
"""
from __future__ import annotations
import os
import re
ENV_VAR = "WIKITOOL_SESSION_ID"
HARNESS_ENV_VARS: tuple[tuple[str, str], ...] = (
("CLAUDE_CODE_SESSION_ID", "claude-code"),
)
_UNSAFE = re.compile(r"[^A-Za-z0-9._-]+")
def _harness_session() -> tuple[str, str] | None:
"""The first harness variable that is actually set, as `(value, harness)`."""
for var, harness in HARNESS_ENV_VARS:
value = os.environ.get(var)
if value:
return value, harness
return None
def session_id() -> str:
explicit = os.environ.get(ENV_VAR)
if explicit:
return explicit
harness = _harness_session()
if harness:
return harness[0]
return str(os.getppid())
def session_id_source() -> str:
"""Where the id in `session_id()` came from - `ENV_VAR`, a harness
variable name (with the harness named alongside it), or the `getppid()`
fallback. `doctor`, `budget status` and the `wikitool` source's
`session.start` event all read this so a session - or a trace - can say
what it was keyed on, not just what the id happened to be."""
if os.environ.get(ENV_VAR):
return ENV_VAR
harness = _harness_session()
if harness:
_, name = harness
var = next(v for v, h in HARNESS_ENV_VARS if h == name)
return f"{var} ({name})"
return "getppid() fallback"
def session_slug(value: str | None = None) -> str:
"""A session id that is safe as a single directory name.
`ingest-large-tree.md` hands out ids like `<runkey>/u2`, so the separator
has to survive as a name rather than becoming a nested directory.
"""
return _UNSAFE.sub("__", value or session_id()).strip("_") or "unknown"