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
This commit is contained in:
+51
-1
@@ -59,11 +59,15 @@ concern - readable here, never shipped as something to parse.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6.1.0-beta.4 - 2026-09-16 - version notes antwortet auf einer ausgelieferten Instanz aus dem Release-Feed
|
## 6.1.0-beta.5 - 2026-09-16 - Session-Id-Fallback: Harness-Variable schliesst die Luecke zwischen Telemetrie-Join und Iteration-Budget-Gate
|
||||||
|
|
||||||
**Author:** Torben Nehmer
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
<!-- wikitool:bumps -->
|
<!-- wikitool:bumps -->
|
||||||
|
**High impact**
|
||||||
|
- Session-Id-Fallback: Harness-Variable schliesst die Luecke zwischen Telemetrie-Join und Iteration-Budget-Gate
|
||||||
|
|
||||||
|
**Medium impact**
|
||||||
- Upgrade-Prozedur als eigene Instruktion statt als Prosa in INSTALL.md
|
- Upgrade-Prozedur als eigene Instruktion statt als Prosa in INSTALL.md
|
||||||
- Migrationsdokument prueft gegen eine festgehaltene Vorher-Ausgabe, Beispielverweis auf die .template-Form
|
- Migrationsdokument prueft gegen eine festgehaltene Vorher-Ausgabe, Beispielverweis auf die .template-Form
|
||||||
- dist upgrade: --take-release nimmt fuer einen lokal geaenderten Pfad die Release-Fassung
|
- dist upgrade: --take-release nimmt fuer einen lokal geaenderten Pfad die Release-Fassung
|
||||||
@@ -252,6 +256,52 @@ Bei der Gelegenheit zwei Eintraege aus `tools/CONTRACT.md` § "Future considerat
|
|||||||
implemented)" entfernt, die dort seit ihrer Umsetzung falsch standen: der MCP-Server-Wrapper und
|
implemented)" entfernt, die dort seit ihrer Umsetzung falsch standen: der MCP-Server-Wrapper und
|
||||||
`dist upgrade` selbst. Beide sind im selben Dokument weiter oben als existierend beschrieben.
|
`dist upgrade` selbst. Beide sind im selben Dokument weiter oben als existierend beschrieben.
|
||||||
|
|
||||||
|
### Session-Id-Fallback: Harness-Variable schliesst die Luecke zwischen Telemetrie-Join und Iteration-Budget-Gate
|
||||||
|
|
||||||
|
Gemessen an einem getracten Lauf (33 `wikitool`-Aufrufe, eine Sitzung): unter Claude Code, dessen
|
||||||
|
Bash-Tool jeden Aufruf in einer frisch initialisierten Shell ausfuehrt, fiel `chemenu.session`
|
||||||
|
ohne gesetztes `WIKITOOL_SESSION_ID` auf `os.getppid()` zurueck - eine neue "Sitzung" pro Aufruf.
|
||||||
|
Der Lauf zerfiel so in 21 Telemetrie-Buckets (hoechster Bucket: 3 von 33 Aufrufen), und das
|
||||||
|
Iteration-Budget-Gate (60 Aufrufe, Loop-Breaker bei 3 identischen in Folge) sah nie mehr als 3 von
|
||||||
|
60 - strukturell unerreichbar, obwohl `AGENTS.md` es als eine der vier code-durchgesetzten
|
||||||
|
Sicherungen fuehrt. Derselbe Bruch traf den Telemetrie-Join: Hook-Events (`prompt.submitted`)
|
||||||
|
trugen die Harness-UUID, `wikitool.call`-Events die wechselnde PID - kein gemeinsamer Schluessel,
|
||||||
|
und `eval score` bewertete 1-3 Aufrufe statt 33.
|
||||||
|
|
||||||
|
`chemenu.session` bekommt eine dritte Stufe zwischen der expliziten Variable und dem
|
||||||
|
PID-Fallback: eine kleine Registry bekannter Harness-Session-Variablen (`HARNESS_ENV_VARS`),
|
||||||
|
heute mit einem verifizierten Eintrag, `CLAUDE_CODE_SESSION_ID`. Verifiziert heisst: gegen eine
|
||||||
|
echte Sitzung gemessen, dass die Variable ueber Tool-Aufrufe hinweg stabil bleibt (anders als die
|
||||||
|
Shell-PID) und exakt der Wert ist, den der `UserPromptSubmit`-Hook in die Trace schreibt - der
|
||||||
|
Wert wird unveraendert als Schluessel uebernommen, kein Praefix, keine Umschreibung, sonst waere
|
||||||
|
der Join wieder zerstoert. Ein Eintrag wird nur nach genau dieser Verifikation aufgenommen: ein
|
||||||
|
Variablenname, der zufaellig existiert und etwas anderes bedeutet, waere ein stillerer Fehler als
|
||||||
|
der PID-Fallback, den er ersetzt.
|
||||||
|
|
||||||
|
`run_budget`s Zustandsdatei (`budget.json`) traegt je Eintrag jetzt die Herkunft seiner Id; faellt
|
||||||
|
dieselbe Id-Zeichenkette unter eine andere Herkunft als die gespeicherte, beginnt ein neuer
|
||||||
|
Zaehler statt einen fremden zu erben - ein Eintrag ohne das Feld (vor dieser Aenderung
|
||||||
|
geschrieben) behaelt seinen Count unveraendert. `doctor` ist jetzt dreiwertig (`OK` fuer eine
|
||||||
|
explizite Variable oder eine erkannte Harness-Variable, `WARN` nur noch fuer den reinen
|
||||||
|
PID-Fallback), und sowohl `budget status` als auch der `session.start`-Event der `wikitool`-
|
||||||
|
Telemetriequelle nennen die Herkunft der Id.
|
||||||
|
|
||||||
|
Im selben Lauf gemessener Nebenbefund auf der Emitter-Seite: ein durch eine geschlossene Pipe
|
||||||
|
abgebrochener, ansonsten erfolgreicher Aufruf (`... | head`) stand mit `exit_code: 1` in der
|
||||||
|
Trace - Click faengt `BrokenPipeError` selbst ab und erzwingt `sys.exit(1)`, ununterscheidbar von
|
||||||
|
einem echten Fehler. `cli.py` installiert jetzt vor jedem Dispatch einen Wrapper um
|
||||||
|
`stdout`/`stderr`, der einen EPIPE-Schreibfehler schluckt, bevor Click ihn sieht, und markiert den
|
||||||
|
Trace-Eintrag stattdessen mit `stdout_truncated: true` bei unveraendertem, dem tatsaechlichen
|
||||||
|
Kommandoerfolg entsprechendem `exit_code`.
|
||||||
|
|
||||||
|
Reproduziert mit Tests, die echte Subprozesse statt In-Process-Aufrufe verwenden - `os.getppid()`
|
||||||
|
ist sonst ueber die Testlaufzeit hinweg konstant: 61 Aufrufe aus je eigenem Prozess mit nur der
|
||||||
|
Harness-Variablen loesen das Gate jetzt aus, drei identische ebenso den Loop-Breaker; vor dieser
|
||||||
|
Aenderung waeren beide Tests gruen und blind gewesen.
|
||||||
|
|
||||||
|
`--minor`: additiv (ein neues optionales `source`-Feld in `budget.json`, die Id faellt weiterhin
|
||||||
|
auf `getppid()` zurueck, wo keine Variable greift), keine der beiden Drop-in-Richtungen verletzt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6.0.1 - 2026-09-16 - docs toc/verify erreichen die .template-Form einer Referenzdatei
|
## 6.0.1 - 2026-09-16 - docs toc/verify erreichen die .template-Form einer Referenzdatei
|
||||||
|
|||||||
@@ -57,7 +57,21 @@ flowchart TD
|
|||||||
- **Hooks enrich.** They add the tool calls the repo layer cannot see: file reads, greps,
|
- **Hooks enrich.** They add the tool calls the repo layer cannot see: file reads, greps,
|
||||||
shell commands, prompts.
|
shell commands, prompts.
|
||||||
|
|
||||||
Everything joins on `WIKITOOL_SESSION_ID`.
|
Everything joins on one session id, resolved the same way by every source that has to pick
|
||||||
|
one - see `chemenu.session`. The chain is `WIKITOOL_SESSION_ID`, then a harness's own session
|
||||||
|
variable where one is registered (`chemenu.session.HARNESS_ENV_VARS` - Claude Code's
|
||||||
|
`CLAUDE_CODE_SESSION_ID` today), then the parent process id. The middle step exists because
|
||||||
|
the last one does not survive a harness that runs every tool call in its own freshly
|
||||||
|
initialised shell: `os.getppid()` is then a new "session" per call, and neither the join nor
|
||||||
|
the Iteration Budget Gate below can see more than one or two calls of a real run. A harness
|
||||||
|
only earns an entry in that chain once a live session has been observed setting the variable,
|
||||||
|
confirmed to be the exact id its own hooks write elsewhere in a trace - a name that merely
|
||||||
|
looks plausible would mis-key a session more quietly than the pid fallback it replaced.
|
||||||
|
|
||||||
|
A trace hook that only *observes* tool calls (a `PreToolUse`/`PostToolUse`-style wiring) does
|
||||||
|
not by itself fix a harness whose events carry a different id than `wikitool`'s own emitter -
|
||||||
|
the two still would not join. Wiring such a hook is only worth doing once this fallback chain
|
||||||
|
already keys both sides on the same id.
|
||||||
|
|
||||||
## The trace
|
## The trace
|
||||||
|
|
||||||
@@ -68,7 +82,7 @@ is [tools/chemenu/telemetry/schema.py](tools/chemenu/telemetry/schema.py).
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `v` | Schema version |
|
| `v` | Schema version |
|
||||||
| `ts` | ISO-8601 UTC, microsecond precision |
|
| `ts` | ISO-8601 UTC, microsecond precision |
|
||||||
| `session_id` | The join key. `WIKITOOL_SESSION_ID`, else the parent process id |
|
| `session_id` | The join key - `chemenu.session`'s fallback chain: `WIKITOOL_SESSION_ID`, else a registered harness variable, else the parent process id |
|
||||||
| `pid`, `seq` | `seq` counts **within one process**. Sort a trace by `(ts, pid, seq)` |
|
| `pid`, `seq` | `seq` counts **within one process**. Sort a trace by `(ts, pid, seq)` |
|
||||||
| `source` | `wikitool`, `runner`, or a harness name |
|
| `source` | `wikitool`, `runner`, or a harness name |
|
||||||
| `event` | See below |
|
| `event` | See below |
|
||||||
|
|||||||
+1
-1
@@ -249,7 +249,7 @@ Ausnahmen (`kb/CONVENTIONS.md`, `kb/*/COLLECTION.md`, `.wikitool-kb.json`) in
|
|||||||
| Variable | Zweck | Fallback |
|
| Variable | Zweck | Fallback |
|
||||||
|----------|-------|----------|
|
|----------|-------|----------|
|
||||||
| `WIKI_AUTHOR` | Override für den Autornamen neuer Source-Seiten | `git config user.name` - fehlt beides, bricht `new` mit `ERROR` ab |
|
| `WIKI_AUTHOR` | Override für den Autornamen neuer Source-Seiten | `git config user.name` - fehlt beides, bricht `new` mit `ERROR` ab |
|
||||||
| `WIKITOOL_SESSION_ID` | Scopt das Iteration-Budget-Gate auf eine Aufgabe statt auf ein Terminal | Parent-Process-ID (siehe [instructions/session-setup.md](instructions/session-setup.md)) |
|
| `WIKITOOL_SESSION_ID` | Scopt das Iteration-Budget-Gate auf eine Aufgabe statt auf ein Terminal | Eine vom Harness selbst gesetzte Sitzungs-Variable, wo eine bekannt ist (z. B. `CLAUDE_CODE_SESSION_ID`), sonst die Parent-Process-ID (siehe [instructions/session-setup.md](instructions/session-setup.md)) |
|
||||||
| `WIKITOOL_UPDATE_URL` | Release-Feed, den `version check` abfragt | Wert aus `.wikitool-release.json`, sonst der Feed der Ursprungs-Instanz |
|
| `WIKITOOL_UPDATE_URL` | Release-Feed, den `version check` abfragt | Wert aus `.wikitool-release.json`, sonst der Feed der Ursprungs-Instanz |
|
||||||
| `WIKITOOL_UPDATE_TOKEN` | Gitea-Token für den Release-Feed | keiner - gegen `torben/chemenu` nicht nötig, nur für einen privaten Fork (siehe unten) |
|
| `WIKITOOL_UPDATE_TOKEN` | Gitea-Token für den Release-Feed | keiner - gegen `torben/chemenu` nicht nötig, nur für einen privaten Fork (siehe unten) |
|
||||||
| `CHEMENU_ROOT` | Auf welchen Korpus das Paket zeigt - für einen Aufrufer, der nicht im Checkout selbst liegt | der Checkout, in dem das Paket liegt (`tools/wikitool` verhält sich ohne die Variable unverändert) |
|
| `CHEMENU_ROOT` | Auf welchen Korpus das Paket zeigt - für einen Aufrufer, der nicht im Checkout selbst liegt | der Checkout, in dem das Paket liegt (`tools/wikitool` verhält sich ohne die Variable unverändert) |
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ description: Scope the wikitool iteration budget to the task by exporting a stab
|
|||||||
# Scope the session budget
|
# Scope the session budget
|
||||||
|
|
||||||
Every `wikitool` call is counted against a per-session iteration budget. A "session" is keyed
|
Every `wikitool` call is counted against a per-session iteration budget. A "session" is keyed
|
||||||
by `WIKITOOL_SESSION_ID`, falling back to the parent process id when that variable is unset.
|
by a fallback chain (`chemenu.session`): `WIKITOOL_SESSION_ID` first, then a harness's own
|
||||||
|
session variable where one is registered (`CLAUDE_CODE_SESSION_ID` today), then the parent
|
||||||
|
process id.
|
||||||
|
|
||||||
Without an explicit id, the budget is scoped to whichever shell happened to run the command,
|
Without an explicit id, and on a harness with no registered variable, the budget is scoped to
|
||||||
so a task spanning several terminals is counted as several sessions - and one that reuses a
|
whichever shell happened to run the command, so a task spanning several terminals is counted as
|
||||||
shell inherits an unrelated count.
|
several sessions - and one that reuses a shell inherits an unrelated count.
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
@@ -25,21 +27,31 @@ tools/wikitool sync
|
|||||||
|
|
||||||
**An `export` only carries if the shell carries.** Several agent harnesses run every tool call in
|
**An `export` only carries if the shell carries.** Several agent harnesses run every tool call in
|
||||||
a freshly initialised shell: the working directory survives, shell state - environment variables,
|
a freshly initialised shell: the working directory survives, shell state - environment variables,
|
||||||
functions - does not, so the variable is gone by the next call and each call falls back to its own
|
functions - does not, so the variable is gone by the next call and each call falls back to whatever
|
||||||
parent pid. The budget then counts a run of thirty calls as thirty sessions of one, and neither
|
the chain's next step resolves to.
|
||||||
the 60-call ceiling nor the loop-breaker can ever trip. On such a harness, pass the id **inline on
|
|
||||||
every call** instead, keeping the same value for the whole task:
|
On a harness with a registered variable in that chain (Claude Code, via `CLAUDE_CODE_SESSION_ID`),
|
||||||
|
the fallback already keeps every call in one bucket without this step - but it scopes to the
|
||||||
|
*whole* harness session, not to this one task, so a long-running session can carry unrelated prior
|
||||||
|
work into the same count. Setting `WIKITOOL_SESSION_ID` explicitly still narrows the bucket to the
|
||||||
|
task at hand, and remains the only way to scope it at all on a harness with no registered
|
||||||
|
variable - each call falls back to its own parent pid there, and neither the 60-call ceiling nor
|
||||||
|
the loop-breaker can ever trip (measured directly on a real upgrade run: 33 `wikitool` calls in
|
||||||
|
one task split into 21 telemetry buckets under the pid fallback alone). On such a harness, pass
|
||||||
|
the id **inline on every call** instead of `export`, keeping the same value for the whole task:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
WIKITOOL_SESSION_ID="wiki-1234" tools/wikitool sync
|
WIKITOOL_SESSION_ID="wiki-1234" tools/wikitool sync
|
||||||
WIKITOOL_SESSION_ID="wiki-1234" tools/wikitool new entity --name "..."
|
WIKITOOL_SESSION_ID="wiki-1234" tools/wikitool new entity --name "..."
|
||||||
```
|
```
|
||||||
|
|
||||||
Which of the two applies is answerable in one call: run `tools/wikitool budget status` twice in
|
Which of the three applies is answerable in one call: run `tools/wikitool budget status` twice in
|
||||||
separate calls after exporting, and see whether it names the same id both times.
|
separate calls, and see whether it names the same id both times, and where that id came from -
|
||||||
|
`budget status` prints both.
|
||||||
|
|
||||||
Check the current state at any time with `tools/wikitool budget status`, which is never
|
Check the current state at any time with `tools/wikitool budget status`, which is never
|
||||||
counted against the budget itself and prints the id it is counting under.
|
counted against the budget itself and prints the id it is counting under, and its origin
|
||||||
|
(`WIKITOOL_SESSION_ID`, a named harness variable, or the parent-pid fallback).
|
||||||
|
|
||||||
**Why `sync` here, not just at publish time.** `publish` already pulls before it pushes, but a
|
**Why `sync` here, not just at publish time.** `publish` already pulls before it pushes, but a
|
||||||
session that runs many `wikitool` calls before its first `publish` (an ingest, a multi-page
|
session that runs many `wikitool` calls before its first `publish` (an ingest, a multi-page
|
||||||
|
|||||||
+7
-4
@@ -214,7 +214,7 @@ tools/wikitool <command> --help
|
|||||||
|
|
||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `doctor [--json]` | Check that this instance is correctly configured: dependencies (Python, ripgrep), author resolution, stack version, git identity/branch/remote, published skills, kb/raw/reports/work/instructions structure, personalization (`USER.md`/`SOUL.md` present **and** filled - a file still carrying the template's sentinel is a `FAIL`, since a renamed template is not a filled one), the KB conventions (`kb/CONVENTIONS.md` present, unsentinelled, and naming all three tool-owned section headings - a `FAIL` on any of the three, because `xref`/`cite` write out of it), the environment note (`ENVIRONMENT.md` - optional, so absent is `OK`; a still-templated one is a `WARN`), generated files, whether the MCP `submit` tool is armed (`.wikitool-upload.json` present/absent/malformed, its limits, and how many submissions are waiting in `mcp-upload/` - absent is `OK` and means the write path does not exist at all, malformed is the one `FAIL` here, since a broken opt-in must not silently disable the limits it exists to enforce), `WIKITOOL_SESSION_ID`, and telemetry state (on/off, why - installation-form default, `.wikitool-telemetry.json`, or `WIKI_TRACE` - and the current session count/byte total against both caps; never `FAIL`, see [EVALS.md](../EVALS.md)). Read-only, exit 1 only on a `FAIL` (a missing remote, session id, or `VERSION` is a `WARN`, not a fault). Exempt from the Iteration Budget Gate |
|
| `doctor [--json]` | Check that this instance is correctly configured: dependencies (Python, ripgrep), author resolution, stack version, git identity/branch/remote, published skills, kb/raw/reports/work/instructions structure, personalization (`USER.md`/`SOUL.md` present **and** filled - a file still carrying the template's sentinel is a `FAIL`, since a renamed template is not a filled one), the KB conventions (`kb/CONVENTIONS.md` present, unsentinelled, and naming all three tool-owned section headings - a `FAIL` on any of the three, because `xref`/`cite` write out of it), the environment note (`ENVIRONMENT.md` - optional, so absent is `OK`; a still-templated one is a `WARN`), generated files, whether the MCP `submit` tool is armed (`.wikitool-upload.json` present/absent/malformed, its limits, and how many submissions are waiting in `mcp-upload/` - absent is `OK` and means the write path does not exist at all, malformed is the one `FAIL` here, since a broken opt-in must not silently disable the limits it exists to enforce), the session id source (`OK` for `WIKITOOL_SESSION_ID` or a registered harness variable, `WARN` only for the bare parent-pid fallback - see `chemenu.session`), and telemetry state (on/off, why - installation-form default, `.wikitool-telemetry.json`, or `WIKI_TRACE` - and the current session count/byte total against both caps; never `FAIL`, see [EVALS.md](../EVALS.md)). Read-only, exit 1 only on a `FAIL` (a missing remote, session id, or `VERSION` is a `WARN`, not a fault). Exempt from the Iteration Budget Gate |
|
||||||
|
|
||||||
## Design notes
|
## Design notes
|
||||||
|
|
||||||
@@ -268,9 +268,12 @@ tools/wikitool <command> --help
|
|||||||
section): every invocation is recorded and checked in `main()` (`cli.py`)
|
section): every invocation is recorded and checked in `main()` (`cli.py`)
|
||||||
before Typer dispatches to any subcommand, so it applies uniformly without
|
before Typer dispatches to any subcommand, so it applies uniformly without
|
||||||
each command needing its own opt-in. State lives in the gitignored
|
each command needing its own opt-in. State lives in the gitignored
|
||||||
`tools/.wikitool_session/budget.json`, keyed by `WIKITOOL_SESSION_ID` (or
|
`tools/.wikitool_session/budget.json`, keyed by `chemenu.session`'s fallback
|
||||||
the caller's parent process id as a fallback), so a new terminal/session
|
chain (`WIKITOOL_SESSION_ID`, else a registered harness session variable,
|
||||||
starts with a clean budget. Default ceiling: 60 calls/session, or 3
|
else the caller's parent process id), so a new terminal/session starts with
|
||||||
|
a clean budget - and a bucket whose recorded origin no longer matches the
|
||||||
|
current one starts a fresh count rather than inheriting a stranger's.
|
||||||
|
Default ceiling: 60 calls/session, or 3
|
||||||
identical calls in a row (whichever trips first). A call that left through
|
identical calls in a row (whichever trips first). A call that left through
|
||||||
`_util.fail()` - a rejected argument, or a read-only check reporting
|
`_util.fail()` - a rejected argument, or a read-only check reporting
|
||||||
findings - is refunded: it declined instead of acting, and the contract's own
|
findings - is refunded: it declined instead of acting, and the contract's own
|
||||||
|
|||||||
+92
-6
@@ -3,6 +3,8 @@
|
|||||||
The root AGENTS.md holds the invariants that say when these commands are
|
The root AGENTS.md holds the invariants that say when these commands are
|
||||||
mandatory; tools/CONTRACT.md is the full per-command reference.
|
mandatory; tools/CONTRACT.md is the full per-command reference.
|
||||||
"""
|
"""
|
||||||
|
import errno
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -50,6 +52,82 @@ except ModuleNotFoundError as exc:
|
|||||||
|
|
||||||
from chemenu.telemetry import emit # noqa: E402 - after the dependency check
|
from chemenu.telemetry import emit # noqa: E402 - after the dependency check
|
||||||
|
|
||||||
|
|
||||||
|
class _BrokenPipeSwallow:
|
||||||
|
"""Wraps a stream so a write into a closed pipe is dropped instead of
|
||||||
|
raised - installed on `sys.stdout`/`sys.stderr` before Typer/Click ever
|
||||||
|
run, so Click's own broken-pipe handling (`click.core.BaseCommand.main`)
|
||||||
|
never gets the chance to fire.
|
||||||
|
|
||||||
|
Why not just read Click's outcome afterwards: Click already catches this
|
||||||
|
exact case (`OSError` with `errno.EPIPE`) and turns it into `sys.exit(1)`
|
||||||
|
to avoid a traceback - a clean-looking exit, but indistinguishable from a
|
||||||
|
real failure to whatever reads that exit code next. `cli._run_traced`
|
||||||
|
does exactly that: it is the trace, which recorded a truncated-but-
|
||||||
|
otherwise-successful `types describe source | head -1` as a tool error
|
||||||
|
(Gitea #110, measured against a real trace: `exit_code: 1` for a call the
|
||||||
|
very next, unpiped, retry of which showed `exit_code: 0`).
|
||||||
|
|
||||||
|
Swallowing the write here instead means Click's own handler never
|
||||||
|
triggers, so the command finishes through its normal exit path - `0` for
|
||||||
|
an otherwise-successful run - and `sigpipe` on this wrapper is the signal
|
||||||
|
`_run_traced` reads to note the truncation without miscasting it as an
|
||||||
|
error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, wrapped):
|
||||||
|
self._wrapped = wrapped
|
||||||
|
self.sigpipe = False
|
||||||
|
|
||||||
|
def _is_epipe(self, exc: OSError) -> bool:
|
||||||
|
return exc.errno == errno.EPIPE
|
||||||
|
|
||||||
|
def write(self, data):
|
||||||
|
try:
|
||||||
|
return self._wrapped.write(data)
|
||||||
|
except OSError as exc:
|
||||||
|
if not self._is_epipe(exc):
|
||||||
|
raise
|
||||||
|
self.sigpipe = True
|
||||||
|
return len(data)
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
try:
|
||||||
|
self._wrapped.flush()
|
||||||
|
except OSError as exc:
|
||||||
|
if not self._is_epipe(exc):
|
||||||
|
raise
|
||||||
|
self.sigpipe = True
|
||||||
|
|
||||||
|
def __getattr__(self, attr):
|
||||||
|
return getattr(self._wrapped, attr)
|
||||||
|
|
||||||
|
|
||||||
|
def _pacify_real_fd(stream) -> None:
|
||||||
|
"""Redirect a broken stream's real file descriptor to `os.devnull`.
|
||||||
|
|
||||||
|
Swallowing the write in `_BrokenPipeSwallow` is not enough on its own:
|
||||||
|
CPython still flushes the *real* underlying stream automatically at
|
||||||
|
interpreter shutdown, by code this module does not control, and that
|
||||||
|
flush hits the same closed pipe - printing "Exception ignored while
|
||||||
|
flushing sys.stdout" (the well-known CPython caveat; see the standard
|
||||||
|
library docs' "Note on SIGPIPE"). Once a pipe is known broken there is
|
||||||
|
nothing left worth writing to it, so pointing the fd at `/dev/null`
|
||||||
|
makes every later flush - ours or the interpreter's own - a normal
|
||||||
|
write that always succeeds.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
devnull = os.open(os.devnull, os.O_WRONLY)
|
||||||
|
try:
|
||||||
|
os.dup2(devnull, stream.fileno())
|
||||||
|
finally:
|
||||||
|
os.close(devnull)
|
||||||
|
except (OSError, AttributeError):
|
||||||
|
# AttributeError: a stream with no real fd at all (a test double, or
|
||||||
|
# a harness that already replaced sys.stdout with something that
|
||||||
|
# isn't a file) - nothing to redirect, same as the OSError case.
|
||||||
|
pass
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
help="wikitool - deterministic operations for Chemenu (see AGENTS.md).",
|
help="wikitool - deterministic operations for Chemenu (see AGENTS.md).",
|
||||||
no_args_is_help=True,
|
no_args_is_help=True,
|
||||||
@@ -127,6 +205,10 @@ def _run_traced(command: str, args: list[str], charged: bool = False) -> None:
|
|||||||
"""
|
"""
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
exit_code = 0
|
exit_code = 0
|
||||||
|
real_stdout, real_stderr = sys.stdout, sys.stderr
|
||||||
|
stdout_wrap = _BrokenPipeSwallow(real_stdout)
|
||||||
|
stderr_wrap = _BrokenPipeSwallow(real_stderr)
|
||||||
|
sys.stdout, sys.stderr = stdout_wrap, stderr_wrap
|
||||||
try:
|
try:
|
||||||
app()
|
app()
|
||||||
except SystemExit as exc:
|
except SystemExit as exc:
|
||||||
@@ -137,18 +219,22 @@ def _run_traced(command: str, args: list[str], charged: bool = False) -> None:
|
|||||||
exit_code = 1
|
exit_code = 1
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
|
if stdout_wrap.sigpipe:
|
||||||
|
_pacify_real_fd(real_stdout)
|
||||||
|
if stderr_wrap.sigpipe:
|
||||||
|
_pacify_real_fd(real_stderr)
|
||||||
|
sys.stdout, sys.stderr = real_stdout, real_stderr
|
||||||
if charged and _util.declined():
|
if charged and _util.declined():
|
||||||
run_budget.refund()
|
run_budget.refund()
|
||||||
emit(
|
attrs = {
|
||||||
"wikitool",
|
|
||||||
"wikitool.call",
|
|
||||||
{
|
|
||||||
"command": command,
|
"command": command,
|
||||||
"args": args,
|
"args": args,
|
||||||
"exit_code": exit_code,
|
"exit_code": exit_code,
|
||||||
"duration_ms": round((time.monotonic() - started) * 1000, 1),
|
"duration_ms": round((time.monotonic() - started) * 1000, 1),
|
||||||
},
|
}
|
||||||
)
|
if stdout_wrap.sigpipe or stderr_wrap.sigpipe:
|
||||||
|
attrs["stdout_truncated"] = True
|
||||||
|
emit("wikitool", "wikitool.call", attrs)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from chemenu import config, conventions, kb_collections, version as version_mod
|
|||||||
from chemenu.commands import git_publish, instructions_cmd
|
from chemenu.commands import git_publish, instructions_cmd
|
||||||
from chemenu.commands._util import rel_path
|
from chemenu.commands._util import rel_path
|
||||||
from chemenu.session import ENV_VAR as SESSION_ENV_VAR
|
from chemenu.session import ENV_VAR as SESSION_ENV_VAR
|
||||||
|
from chemenu.session import session_id_source as _session_id_source
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
@@ -414,12 +415,23 @@ def check_upload_intake() -> Check:
|
|||||||
|
|
||||||
|
|
||||||
def check_session_id() -> Check:
|
def check_session_id() -> Check:
|
||||||
|
"""Three-valued, not two: an explicit `WIKITOOL_SESSION_ID` and a
|
||||||
|
recognised harness variable (see `chemenu.session.HARNESS_ENV_VARS`) both
|
||||||
|
keep a session's calls in one telemetry/budget bucket, so both are `OK`.
|
||||||
|
Only the `getppid()` fallback - a fresh "session" on every call, on a
|
||||||
|
harness that runs each tool call in its own shell - is a `WARN` (see
|
||||||
|
Gitea #110)."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
if os.environ.get(SESSION_ENV_VAR, "").strip():
|
if os.environ.get(SESSION_ENV_VAR, "").strip():
|
||||||
return Check("session-id", "OK", f"{SESSION_ENV_VAR}={os.environ[SESSION_ENV_VAR]}")
|
return Check("session-id", "OK", f"{SESSION_ENV_VAR}={os.environ[SESSION_ENV_VAR]}")
|
||||||
|
source = _session_id_source()
|
||||||
|
if source != "getppid() fallback":
|
||||||
|
return Check("session-id", "OK", f"scoped by harness variable {source}")
|
||||||
return Check(
|
return Check(
|
||||||
"session-id", "WARN", f"{SESSION_ENV_VAR} is not set - budget falls back to the parent PID",
|
"session-id", "WARN",
|
||||||
|
f"{SESSION_ENV_VAR} is not set and no harness session variable was found - "
|
||||||
|
"budget falls back to the parent PID",
|
||||||
"See instructions/session-setup.md",
|
"See instructions/session-setup.md",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,27 @@ def _session_id_source() -> str:
|
|||||||
return _shared_session_id_source()
|
return _shared_session_id_source()
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_for(state: dict, session_id: str) -> dict:
|
||||||
|
"""The state entry for this session id, starting a fresh counter if the
|
||||||
|
same id string now carries a different origin than the one that wrote it.
|
||||||
|
|
||||||
|
Two different id spaces (a `getppid()` integer, a harness UUID, an
|
||||||
|
explicit `WIKITOOL_SESSION_ID`) are vanishingly unlikely to collide as
|
||||||
|
strings - but "unlikely" is not "impossible", and inheriting a stranger's
|
||||||
|
count on collision is exactly the silent mis-key #110 exists to close.
|
||||||
|
An entry written before this field existed carries no `source` at all and
|
||||||
|
is treated as compatible: it keeps its count rather than being reset the
|
||||||
|
first time this ships, which would throw away real, in-flight state.
|
||||||
|
"""
|
||||||
|
source = _session_id_source()
|
||||||
|
entry = state.get(session_id)
|
||||||
|
if entry is None or (entry.get("source") is not None and entry["source"] != source):
|
||||||
|
entry = {"count": 0, "recent": []}
|
||||||
|
state[session_id] = entry
|
||||||
|
entry.setdefault("source", source)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
def _load_state() -> dict:
|
def _load_state() -> dict:
|
||||||
if not STATE_FILE.exists():
|
if not STATE_FILE.exists():
|
||||||
return {}
|
return {}
|
||||||
@@ -247,7 +268,7 @@ def record_and_check(
|
|||||||
with _state_lock():
|
with _state_lock():
|
||||||
session_id = _session_id()
|
session_id = _session_id()
|
||||||
state = _load_state()
|
state = _load_state()
|
||||||
entry = state.setdefault(session_id, {"count": 0, "recent": []})
|
entry = _entry_for(state, session_id)
|
||||||
recent = entry["recent"]
|
recent = entry["recent"]
|
||||||
|
|
||||||
call_signature = f"{command} {' '.join(args)}".strip()
|
call_signature = f"{command} {' '.join(args)}".strip()
|
||||||
|
|||||||
@@ -4,10 +4,35 @@ 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
|
differently from the way the budget counts calls, a trace could not be read
|
||||||
against the gate that refused it.
|
against the gate that refused it.
|
||||||
|
|
||||||
A "session" is approximated by the parent process of this CLI invocation - the
|
Three-step fallback chain, in order:
|
||||||
agent's shell - unless the caller sets `WIKITOOL_SESSION_ID`. Skills set it
|
|
||||||
explicitly so a session is scoped to a task rather than to a terminal window
|
1. `WIKITOOL_SESSION_ID`, if the caller set one explicitly. Skills set it so a
|
||||||
(see instructions/session-setup.md).
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -16,15 +41,46 @@ import re
|
|||||||
|
|
||||||
ENV_VAR = "WIKITOOL_SESSION_ID"
|
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._-]+")
|
_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:
|
def session_id() -> str:
|
||||||
return os.environ.get(ENV_VAR) or str(os.getppid())
|
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:
|
def session_id_source() -> str:
|
||||||
return ENV_VAR if os.environ.get(ENV_VAR) else "getppid() fallback"
|
"""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:
|
def session_slug(value: str | None = None) -> str:
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from chemenu import config
|
from chemenu import config
|
||||||
from chemenu.session import session_id as current_session_id
|
from chemenu.session import session_id as current_session_id
|
||||||
|
from chemenu.session import session_id_source as current_session_id_source
|
||||||
from chemenu.session import session_slug
|
from chemenu.session import session_slug
|
||||||
from chemenu.telemetry import policy, schema, scrub
|
from chemenu.telemetry import policy, schema, scrub
|
||||||
|
|
||||||
@@ -155,14 +156,22 @@ def _seed_session_header(target: Path, source: str, session: str) -> None:
|
|||||||
except FileExistsError:
|
except FileExistsError:
|
||||||
return
|
return
|
||||||
with handle:
|
with handle:
|
||||||
header = schema.make_event(
|
attrs = {
|
||||||
source,
|
|
||||||
"session.start",
|
|
||||||
{
|
|
||||||
"harness": source,
|
"harness": source,
|
||||||
"completeness": list(schema.HARNESS_CAPABILITIES.get(source, ())),
|
"completeness": list(schema.HARNESS_CAPABILITIES.get(source, ())),
|
||||||
"synthesized": True,
|
"synthesized": True,
|
||||||
},
|
}
|
||||||
|
# Only the `wikitool` source resolves its own session id through
|
||||||
|
# chemenu.session's fallback chain - every other source hands `emit()`
|
||||||
|
# an id its own hook payload already carried. Naming the chain's
|
||||||
|
# outcome here is what lets a trace say what it was keyed on, not just
|
||||||
|
# what the id happened to be (Gitea #110).
|
||||||
|
if source == "wikitool":
|
||||||
|
attrs["session_origin"] = current_session_id_source()
|
||||||
|
header = schema.make_event(
|
||||||
|
source,
|
||||||
|
"session.start",
|
||||||
|
attrs,
|
||||||
session_id=session,
|
session_id=session,
|
||||||
seq=_next_seq(),
|
seq=_next_seq(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import pytest
|
|||||||
|
|
||||||
from chemenu import config, conventions
|
from chemenu import config, conventions
|
||||||
from chemenu.frontmatter_io import write_page
|
from chemenu.frontmatter_io import write_page
|
||||||
|
from chemenu.session import HARNESS_ENV_VARS
|
||||||
from chemenu.telemetry import policy as telemetry_policy
|
from chemenu.telemetry import policy as telemetry_policy
|
||||||
from chemenu.type_resolver import resolver
|
from chemenu.type_resolver import resolver
|
||||||
|
|
||||||
@@ -13,6 +14,13 @@ from chemenu.type_resolver import resolver
|
|||||||
# that a test which needs one sets it itself and the rest run against the
|
# that a test which needs one sets it itself and the rest run against the
|
||||||
# tool's own defaults. `WIKI_TRACE_DIR` is deliberately absent: it is not a
|
# tool's own defaults. `WIKI_TRACE_DIR` is deliberately absent: it is not a
|
||||||
# leak but the redirect `isolated_trace_dir` installs one fixture below.
|
# leak but the redirect `isolated_trace_dir` installs one fixture below.
|
||||||
|
#
|
||||||
|
# The harness variables from `chemenu.session.HARNESS_ENV_VARS` are pulled in
|
||||||
|
# here rather than duplicated: this suite runs *inside* Claude Code, so
|
||||||
|
# `CLAUDE_CODE_SESSION_ID` is genuinely set in the real environment - without
|
||||||
|
# clearing it, every session-fallback test would silently pick up this
|
||||||
|
# session's real id instead of exercising the fallback it means to test
|
||||||
|
# (Gitea #110).
|
||||||
_WIKITOOL_ENV = (
|
_WIKITOOL_ENV = (
|
||||||
"WIKI_AUTHOR",
|
"WIKI_AUTHOR",
|
||||||
"WIKI_TRACE",
|
"WIKI_TRACE",
|
||||||
@@ -24,7 +32,7 @@ _WIKITOOL_ENV = (
|
|||||||
"WIKITOOL_UPDATE_URL",
|
"WIKITOOL_UPDATE_URL",
|
||||||
"WIKITOOL_UPDATE_TOKEN",
|
"WIKITOOL_UPDATE_TOKEN",
|
||||||
"CHEMENU_ROOT",
|
"CHEMENU_ROOT",
|
||||||
)
|
) + tuple(var for var, _harness in HARNESS_ENV_VARS)
|
||||||
|
|
||||||
# Environment git reads for identity or for where its repo lives. A stray
|
# Environment git reads for identity or for where its repo lives. A stray
|
||||||
# `GIT_DIR` would point every fixture repo at the developer's checkout; the
|
# `GIT_DIR` would point every fixture repo at the developer's checkout; the
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""The CLI dispatch wrapper: the budget/trace hook every command runs
|
||||||
|
through (`cli.main`/`cli._run_traced`), and the broken-pipe handling that
|
||||||
|
sits alongside it.
|
||||||
|
|
||||||
|
Gitea #110's SIGPIPE side finding: a successful call whose output is cut off
|
||||||
|
by a closed pipe (`wikitool types describe source | head -1`) used to record
|
||||||
|
`exit_code: 1` in the trace - indistinguishable from a real tool failure, and
|
||||||
|
reproduced verbatim by the very next, unpiped retry of the same command
|
||||||
|
showing `exit_code: 0`. `cli._BrokenPipeSwallow` and `cli._pacify_real_fd`
|
||||||
|
exist to close that gap; these tests exercise them without depending on a
|
||||||
|
real OS pipe or Click's own internal handling, which is exactly the moving
|
||||||
|
part being routed around.
|
||||||
|
"""
|
||||||
|
import errno
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from chemenu import cli
|
||||||
|
|
||||||
|
|
||||||
|
def read_lines(path):
|
||||||
|
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
|
||||||
|
|
||||||
|
|
||||||
|
class _FailingStream:
|
||||||
|
"""Raises EPIPE on the `fail_on`-th write - a fake stream standing in for
|
||||||
|
a real pipe whose reader has already closed."""
|
||||||
|
|
||||||
|
def __init__(self, fail_on=1):
|
||||||
|
self.fail_on = fail_on
|
||||||
|
self.calls = 0
|
||||||
|
self.written = []
|
||||||
|
self.flushed = False
|
||||||
|
|
||||||
|
def write(self, data):
|
||||||
|
self.calls += 1
|
||||||
|
if self.calls >= self.fail_on:
|
||||||
|
raise OSError(errno.EPIPE, "Broken pipe")
|
||||||
|
self.written.append(data)
|
||||||
|
return len(data)
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
self.flushed = True
|
||||||
|
|
||||||
|
def isatty(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# --- _BrokenPipeSwallow ---
|
||||||
|
|
||||||
|
def test_broken_pipe_swallow_absorbs_epipe_on_write():
|
||||||
|
swallow = cli._BrokenPipeSwallow(_FailingStream(fail_on=1))
|
||||||
|
n = swallow.write("hello")
|
||||||
|
assert n == len("hello")
|
||||||
|
assert swallow.sigpipe is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_pipe_swallow_absorbs_epipe_on_flush():
|
||||||
|
class _FlushFails:
|
||||||
|
def flush(self):
|
||||||
|
raise OSError(errno.EPIPE, "Broken pipe")
|
||||||
|
|
||||||
|
swallow = cli._BrokenPipeSwallow(_FlushFails())
|
||||||
|
swallow.flush() # does not raise
|
||||||
|
assert swallow.sigpipe is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_pipe_swallow_reraises_unrelated_oserrors():
|
||||||
|
class _Explodes:
|
||||||
|
def write(self, data):
|
||||||
|
raise OSError(errno.ENOSPC, "No space left on device")
|
||||||
|
|
||||||
|
swallow = cli._BrokenPipeSwallow(_Explodes())
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
swallow.write("x")
|
||||||
|
assert swallow.sigpipe is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_pipe_swallow_passes_through_normal_writes():
|
||||||
|
wrapped = _FailingStream(fail_on=99)
|
||||||
|
swallow = cli._BrokenPipeSwallow(wrapped)
|
||||||
|
swallow.write("hello")
|
||||||
|
assert wrapped.written == ["hello"]
|
||||||
|
assert swallow.sigpipe is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_pipe_swallow_proxies_unknown_attributes():
|
||||||
|
wrapped = _FailingStream()
|
||||||
|
swallow = cli._BrokenPipeSwallow(wrapped)
|
||||||
|
assert swallow.isatty() is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- _pacify_real_fd ---
|
||||||
|
|
||||||
|
def test_pacify_real_fd_is_a_best_effort_noop_without_a_real_descriptor():
|
||||||
|
class _RaisesOSError:
|
||||||
|
def fileno(self):
|
||||||
|
raise OSError("not a real fd in this test")
|
||||||
|
|
||||||
|
class _HasNoFilenoAtAll:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cli._pacify_real_fd(_RaisesOSError()) # must not raise
|
||||||
|
cli._pacify_real_fd(_HasNoFilenoAtAll()) # must not raise either
|
||||||
|
|
||||||
|
|
||||||
|
# --- _run_traced: the trace records what actually happened ---
|
||||||
|
|
||||||
|
def test_a_write_cut_off_by_a_closed_pipe_is_not_recorded_as_an_error(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("WIKI_TRACE_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setenv("WIKITOOL_SESSION_ID", "sigpipe-unit")
|
||||||
|
stub = _FailingStream(fail_on=2) # first write succeeds, second hits EPIPE
|
||||||
|
monkeypatch.setattr(sys, "stdout", stub)
|
||||||
|
|
||||||
|
def fake_app():
|
||||||
|
sys.stdout.write("line one\n")
|
||||||
|
sys.stdout.write("line two\n") # truncated here, like `| head -1`
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "app", fake_app)
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
cli._run_traced("types", ["describe", "source"])
|
||||||
|
assert exc.value.code == 0
|
||||||
|
|
||||||
|
records = read_lines(tmp_path / "sigpipe-unit" / "trace.jsonl")
|
||||||
|
call = next(r for r in records if r["event"] == "wikitool.call")
|
||||||
|
assert call["attrs"]["exit_code"] == 0
|
||||||
|
assert call["attrs"]["stdout_truncated"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_real_failure_is_still_recorded_as_one(monkeypatch, tmp_path):
|
||||||
|
"""The unrelated-error path stays exactly as before: an actual failure
|
||||||
|
keeps its exit code and carries no truncation flag."""
|
||||||
|
monkeypatch.setenv("WIKI_TRACE_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setenv("WIKITOOL_SESSION_ID", "real-failure-unit")
|
||||||
|
|
||||||
|
def fake_app():
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "app", fake_app)
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
cli._run_traced("new", ["entity", "--name", ""])
|
||||||
|
assert exc.value.code == 1
|
||||||
|
|
||||||
|
records = read_lines(tmp_path / "real-failure-unit" / "trace.jsonl")
|
||||||
|
call = next(r for r in records if r["event"] == "wikitool.call")
|
||||||
|
assert call["attrs"]["exit_code"] == 1
|
||||||
|
assert "stdout_truncated" not in call["attrs"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_ordinary_call_restores_the_real_streams_afterwards(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("WIKI_TRACE_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setenv("WIKITOOL_SESSION_ID", "restore-unit")
|
||||||
|
real_stdout, real_stderr = sys.stdout, sys.stderr
|
||||||
|
|
||||||
|
def fake_app():
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "app", fake_app)
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
cli._run_traced("lint", [])
|
||||||
|
assert sys.stdout is real_stdout
|
||||||
|
assert sys.stderr is real_stderr
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
|
from chemenu import config
|
||||||
from chemenu.commands import run_budget
|
from chemenu.commands import run_budget
|
||||||
|
|
||||||
|
|
||||||
@@ -137,6 +141,39 @@ def test_override_bypasses_both_gates():
|
|||||||
assert state["test-session"]["count"] == 40
|
assert state["test-session"]["count"] == 40
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bucket_without_a_recorded_origin_keeps_its_count():
|
||||||
|
"""Grandfathering (#110 decision 1): an entry written before this field
|
||||||
|
existed must not be reset the moment this ships - that would throw away
|
||||||
|
real, in-flight state on every existing instance's first call after
|
||||||
|
upgrading."""
|
||||||
|
run_budget._save_state({"test-session": {"count": 5, "recent": ["lint"]}})
|
||||||
|
run_budget.record_and_check("lint", [], override=False)
|
||||||
|
entry = run_budget._load_state()["test-session"]
|
||||||
|
assert entry["count"] == 6
|
||||||
|
assert entry["source"] == "WIKITOOL_SESSION_ID"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bucket_with_a_different_recorded_origin_starts_over():
|
||||||
|
"""The same id string, stamped by a different origin than the one
|
||||||
|
recorded, is treated as a stranger's bucket rather than inherited - the
|
||||||
|
mechanism behind #110's 'no bucket is silently reinterpreted' criterion."""
|
||||||
|
run_budget._save_state(
|
||||||
|
{"test-session": {"count": 40, "recent": ["lint"], "source": "getppid() fallback"}}
|
||||||
|
)
|
||||||
|
run_budget.record_and_check("lint", [], override=False)
|
||||||
|
entry = run_budget._load_state()["test-session"]
|
||||||
|
assert entry["count"] == 1
|
||||||
|
assert entry["source"] == "WIKITOOL_SESSION_ID"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bucket_with_the_same_recorded_origin_keeps_counting():
|
||||||
|
run_budget._save_state(
|
||||||
|
{"test-session": {"count": 7, "recent": ["lint"], "source": "WIKITOOL_SESSION_ID"}}
|
||||||
|
)
|
||||||
|
run_budget.record_and_check("lint", [], override=False)
|
||||||
|
assert run_budget._load_state()["test-session"]["count"] == 8
|
||||||
|
|
||||||
|
|
||||||
def test_save_state_writes_atomically_and_leaves_no_tmp_file(isolated_state):
|
def test_save_state_writes_atomically_and_leaves_no_tmp_file(isolated_state):
|
||||||
run_budget._save_state({"test-session": {"count": 1, "recent": []}})
|
run_budget._save_state({"test-session": {"count": 1, "recent": []}})
|
||||||
assert isolated_state.exists()
|
assert isolated_state.exists()
|
||||||
@@ -228,3 +265,51 @@ def test_status_command_reports_count(capsys):
|
|||||||
run_budget.status_command()
|
run_budget.status_command()
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "Calls so far: 1" in out
|
assert "Calls so far: 1" in out
|
||||||
|
|
||||||
|
|
||||||
|
# --- gate reproduced across separate processes (Gitea #110) ---
|
||||||
|
#
|
||||||
|
# `isolated_state`'s in-process monkeypatching cannot exercise the actual bug:
|
||||||
|
# `os.getppid()` is constant within one test process. These spawn a fresh
|
||||||
|
# Python subprocess per call - the same shape as Claude Code's Bash tool,
|
||||||
|
# which runs every `wikitool` invocation in a freshly initialised shell - so
|
||||||
|
# the parent pid really does differ call to call, and only a harness variable
|
||||||
|
# (standing in for `CLAUDE_CODE_SESSION_ID`) can hold the run together.
|
||||||
|
# Before the fallback chain existed, both tests below would be green *and*
|
||||||
|
# blind: every call landed in its own one-or-two-call bucket, and neither
|
||||||
|
# gate could ever see enough of one session to trip.
|
||||||
|
|
||||||
|
def _spawn_call(tmp_path, monkeypatch, *, command="lint", args=(), override=False):
|
||||||
|
monkeypatch.setenv("CHEMENU_ROOT", str(tmp_path))
|
||||||
|
monkeypatch.setenv("CLAUDE_CODE_SESSION_ID", "harness-run")
|
||||||
|
monkeypatch.delenv("WIKITOOL_SESSION_ID", raising=False)
|
||||||
|
code = (
|
||||||
|
"from chemenu.commands import run_budget\n"
|
||||||
|
f"run_budget.record_and_check({command!r}, {list(args)!r}, override={override!r})\n"
|
||||||
|
)
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, "-c", code],
|
||||||
|
cwd=config._PACKAGE_ROOT / "tools",
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_iteration_budget_gate_trips_across_separate_shells(tmp_path, monkeypatch):
|
||||||
|
for i in range(run_budget.DEFAULT_CALL_LIMIT):
|
||||||
|
result = _spawn_call(tmp_path, monkeypatch, args=[f"--pass-{i}"])
|
||||||
|
assert result.returncode == 0, result.stdout + result.stderr
|
||||||
|
|
||||||
|
result = _spawn_call(tmp_path, monkeypatch, args=["--one-too-many"])
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert "Iteration Budget Gate" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_loop_breaker_trips_across_separate_shells(tmp_path, monkeypatch):
|
||||||
|
args = ["add", "--a", "X", "--b", "Y"]
|
||||||
|
for _ in range(run_budget.DEFAULT_LOOP_WINDOW):
|
||||||
|
result = _spawn_call(tmp_path, monkeypatch, command="xref", args=args)
|
||||||
|
assert result.returncode == 0, result.stdout + result.stderr
|
||||||
|
|
||||||
|
result = _spawn_call(tmp_path, monkeypatch, command="xref", args=args)
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert "Loop-Breaker" in result.stdout
|
||||||
|
|||||||
@@ -145,6 +145,30 @@ def test_session_id_falls_back_to_the_parent_process(monkeypatch):
|
|||||||
assert "getppid" in session.session_id_source()
|
assert "getppid" in session.session_id_source()
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_id_prefers_a_harness_variable_over_getppid(monkeypatch):
|
||||||
|
"""The middle link of the chain (Gitea #110): a harness that sets its own
|
||||||
|
session variable, but not WIKITOOL_SESSION_ID, still gets a stable id
|
||||||
|
rather than falling all the way to the per-call parent pid."""
|
||||||
|
monkeypatch.delenv("WIKITOOL_SESSION_ID", raising=False)
|
||||||
|
monkeypatch.setenv("CLAUDE_CODE_SESSION_ID", "30d734d5-live-session")
|
||||||
|
assert session.session_id() == "30d734d5-live-session"
|
||||||
|
assert session.session_id_source() == "CLAUDE_CODE_SESSION_ID (claude-code)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_session_id_still_wins_over_a_harness_variable(monkeypatch):
|
||||||
|
monkeypatch.setenv("WIKITOOL_SESSION_ID", "ingest-handbook/u2")
|
||||||
|
monkeypatch.setenv("CLAUDE_CODE_SESSION_ID", "30d734d5-live-session")
|
||||||
|
assert session.session_id() == "ingest-handbook/u2"
|
||||||
|
assert session.session_id_source() == "WIKITOOL_SESSION_ID"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_harness_id_is_used_unmodified_as_the_key():
|
||||||
|
"""Decision 1 in #110: no prefix, no rewriting - the value has to be
|
||||||
|
exactly what a harness's own hook writes into a trace, or the two would
|
||||||
|
stop joining on the same string."""
|
||||||
|
assert session.HARNESS_ENV_VARS == (("CLAUDE_CODE_SESSION_ID", "claude-code"),)
|
||||||
|
|
||||||
|
|
||||||
def test_the_core_event_set_is_what_every_harness_can_produce():
|
def test_the_core_event_set_is_what_every_harness_can_produce():
|
||||||
"""Guards the degradation rule: if a core event stops being available on one
|
"""Guards the degradation rule: if a core event stops being available on one
|
||||||
harness, this fails rather than the scorer silently reporting zero."""
|
harness, this fails rather than the scorer silently reporting zero."""
|
||||||
@@ -185,6 +209,25 @@ def test_a_reported_session_start_is_not_shadowed_by_a_synthetic_one(monkeypatch
|
|||||||
assert "synthesized" not in records[0]["attrs"]
|
assert "synthesized" not in records[0]["attrs"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_wikitool_s_own_header_names_where_its_session_id_came_from(monkeypatch, tmp_path):
|
||||||
|
"""Only the `wikitool` source resolves its own id through the fallback
|
||||||
|
chain - naming that origin in its own header is what lets a trace say
|
||||||
|
what it was keyed on, not just what the id happened to be (Gitea #110)."""
|
||||||
|
monkeypatch.setenv("WIKI_TRACE_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setenv("CLAUDE_CODE_SESSION_ID", "harness-session")
|
||||||
|
emit_mod.write_event("wikitool", "wikitool.call", {"command": "lint"},
|
||||||
|
session="harness-session")
|
||||||
|
header = read_lines(tmp_path / "harness-session" / "trace.jsonl")[0]
|
||||||
|
assert header["attrs"]["session_origin"] == "CLAUDE_CODE_SESSION_ID (claude-code)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_another_source_s_header_carries_no_session_origin(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("WIKI_TRACE_DIR", str(tmp_path))
|
||||||
|
emit_mod.write_event("copilot-cli", "tool.post", {"tool_name": "bash"}, session="v4")
|
||||||
|
header = read_lines(tmp_path / "v4" / "trace.jsonl")[0]
|
||||||
|
assert "session_origin" not in header["attrs"]
|
||||||
|
|
||||||
|
|
||||||
# --- reading back ---
|
# --- reading back ---
|
||||||
|
|
||||||
def test_a_trace_reads_back_in_time_order(monkeypatch, tmp_path):
|
def test_a_trace_reads_back_in_time_order(monkeypatch, tmp_path):
|
||||||
|
|||||||
Reference in New Issue
Block a user