Skip to content

fix(mention-context): give every run_capture call its own temp files (OPE15-00058) - #23

Merged
andrei-hasna merged 2 commits into
mainfrom
fix/251c5218-mention-context-shared-tempfile
Aug 7, 2026
Merged

fix(mention-context): give every run_capture call its own temp files (OPE15-00058)#23
andrei-hasna merged 2 commits into
mainfrom
fix/251c5218-mention-context-shared-tempfile

Conversation

@andrei-hasna

@andrei-hasna andrei-hasna commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

What this fixes

The prompt-context hook attributed one repository's local HEAD to a different
repository. Every sha it printed was a real sha from a real repository — just
the wrong one — which is why it read as correct and went unnoticed.

run_capture derived its output path from its tag argument alone:

249  out_p = os.path.join(tmpdir, f"{tag}.out")
406  tag="gitlog"          <- constant, in probe_local_head

Repository probes are submitted concurrently against one shared tmpdir, so
every mentioned repository's git log wrote to and read back the same
gitlog.out, and the reader got whatever the last writer left.

The asymmetry is the proof that this was an oversight rather than a design
choice — four temp paths, three already namespaced, one not:

406  tag="gitlog"                        <- constant
460  tag=f"gh-{org}-{word}"              <- namespaced
489  body_p = f"npm-{org}-{word}.body"   <- namespaced (bypasses run_capture)
499  tag=f"npmw-{org}-{word}"            <- namespaced

It needs two or three mentions in one prompt. MAX_TOKENS = 3, so a single
mention produces one probe, no concurrency, and always the correct answer.

Why the fix is in run_capture and not at the call site

Threading org/word into probe_local_head is two lines and matches what its
siblings already do. It also leaves the invariant unenforced, so the next call
site that passes a constant inherits the bug — which is exactly how this one
arose. run_capture owns path construction, so a per-call component there makes
collision impossible by construction rather than by every future author
remembering.

probe_npm's body_p is the one capture path that bypasses run_capture, because
curl writes the body itself via -o while stdout carries the status code. It was
not part of this defect
(org, word) is deduplicated by extract_tokens, so that
name was already unique within a run — but it now takes the same per-call component
so the rule holds for every temp path rather than for most of them.

The tests, and why they are timed rather than looped

A race reproduced by "run it many times and hope" passes on broken code whenever
the interleaving happens not to occur. Both concurrency tests instead force
the losing interleaving:

early writer / late reader   writes at t=0,   exits and reads at t=0.80
late writer  / early reader  writes, exits and reads at t=0.25

Measured on this branch, five runs of each build, station01 at loadavg ~28:

BEFORE   5/5   FAILED (failures=3)
AFTER    5/5   OK

The end-to-end case names the defect exactly:

AssertionError: 'c091c21' != '7f5cdda'
 : beta reported 'c091c21'; its own head is '7f5cdda' and alpha's is 'c091c21'
   — one repository's head was attributed to another

Level 1 drives run_capture with two Python children and no git at all. Level 2
drives probe_local_head end to end with real git against two repositories
built by the test
, so it is hermetic and the expected shas are known exactly.

Real acceptance path

The same three-repo prompt, six runs against each build, scored against checkout
HEADs verified by cross-check to exist only in their own repository (and a
deadbeef negative control that correctly fails everywhere):

loops    82a3acf      logs    146a70e      accounts  27cffd7

PRE-FIX    3 of 6 runs carried at least one wrong attribution
POST-FIX   0 of 6

Sample pre-fix corruption, both shapes present:

run1   loops=146a70e WRONG(logs)   logs=146a70e ok        accounts=27cffd7 ok
run3   loops=82a3acf ok            logs=82a3acf WRONG     accounts=27cffd7 ok
run6   loops=82a3acf ok            logs=absent            accounts=82a3acf WRONG

Those six runs are an observation with its denominator, not an error rate.
The half that matters for testing: 3 of the 6 pre-fix runs were entirely clean,
so a test that exercises the hook once and checks its output can pass on the
unfixed code, and a green single-run result would be indistinguishable from a
real fix. That is why the gate is the deterministic concurrency test.

The duplicate-sha check ships as a canary, not as the gate

Two unrelated repositories cannot share a commit sha, so a block claiming one sha
for two checkouts is self-evidently corrupt with no fixtures needed — cheap to run
against real output in the field. Two tests pin its limits so nobody promotes it:

  • it misses a clean swap, because it detects collision to a common value while
    the defect is wrong attribution, and a swap is pairwise distinct and entirely wrong;
  • it compares resolved checkout paths rather than mention tokens, because
    hasna/loops and hasna/open-loops name one checkout and legitimately share a head.

Adopting Python into this repository

hooks/ was 100% TypeScript, JSON and Markdown — 0 .py files — so this is a real
first, not a formality. Chosen over a new repository (a second home for hook code
is the duplication we are supposed to prevent) and over a TypeScript port
(rewriting 1,182 lines that fire on every prompt is disproportionate to fixing one
temp path).

What it actually required, all of it inside hooks/mention-context/:

  • tsconfig.json already excludes hooks/, so bun run typecheck is unaffected — rc=0.
  • hooks/**/*.test.ts is already discovered by bun test, so a thin wrapper runs the
    Python suite under the existing CI step with no workflow change. It fails rather
    than skips when python3 is absent, and asserts the suite actually collected tests,
    because unittest exits 0 when it collects nothing.
  • A .gitignore for __pycache__: this is the first Python here, the root ignore does
    not cover it, and package.json ships hooks/, so a .pyc would otherwise be
    committed and published. One was caught staged before commit.

The first commit is the file byte-identical to the installed copy
(md5 6c39bfa24a6c0d22693d96f79f58a4e7, cmp rc=0) so the fix is reviewable as a
diff rather than as a 1,182-line import.

Suite state

this branch     1079 tests   1078 pass   1 fail
origin/main     1076 tests   1075 pass   1 fail   (baseline, measured before any change)
bun run typecheck   rc=0

The single failure is a 5000 ms timeout in codewith-native-common.test.ts. It
reproduces on the unmodified base commit in isolation, 3 of 3 runs, at loadavg ~27
— pre-existing and out of scope. An src/mcp/server.test.ts SSE timeout appeared in
one full run and did not recur; that file passes 78/78 in isolation 3/3, and this
branch touches no path outside hooks/mention-context/ (verified: 0 paths outside).

Not installed by this PR

The hook renders into every agent's prompt on every firing. Landing the source and
updating the live path at ~/.hasna/hooks/bin/hasna-mention-context.py are two
separately verified steps; this PR does the first only and does not touch the
running hook.

Side findings — reported, not fixed here

hasna-mention-warm.py does NOT have this defect, and it is a second consumer of
the fixed function.
I checked rather than assumed, and the answer inverted my
expectation:

  • It imports this hook by path and binds it as H
    (HOOK_PATH = os.path.join(HERE, "hasna-mention-context.py"),
    spec_from_file_location at :128, H = load_hook() at :134), then calls
    H.run_capture(...) at :263, :286 and :316. So it inherits this fix
    automatically
    — no second change needed.
  • It has no concurrency at all: no ThreadPoolExecutor, no threading, no
    submit( anywhere in the file, and its token loop at :463 is plainly sequential.
    (Positive control on the same grep invocation: def matches 15.)
  • It does use one constant tag, "warm-projects" at :354, but sequential reuse of a
    tag is safe even on the unfixed code, because each call completes before the next
    begins. It was never exposed.

Two consequences worth carrying: this strengthens fixing run_capture rather than
probe_local_head
, since a second file depends on that function's behaviour; and
the two files must stay co-located at install time, because the warmer resolves
the hook relative to its own directory.

Other machines were not examined. Only station01 was inspected, and this is a
fleet hook, so copies elsewhere may be identical or drifted. Not checked, not claimed.

Refs: OPE15-00058


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…hooks/

The prompt-context hook had no repository home. It renders into every agent's
prompt on every firing, is registered by absolute path in a settings file, sits
outside any git repo, carries no managed-render marker, and its change control
was edit-in-place with a timestamped .bak copy.

This commit is the file EXACTLY as installed, byte-identical, so the fix that
follows is reviewable as a diff rather than as a 1,182-line import.

  installed md5  6c39bfa24a6c0d22693d96f79f58a4e7
  adopted   md5  6c39bfa24a6c0d22693d96f79f58a4e7
  cmp       byte-identical, rc=0

No behaviour changes here, and nothing is installed to the live path: adopting
the source and updating the running hook are separately verified steps.

This is the first Python under hooks/ — every other hook is TypeScript. Chosen
over a new repository (a second home for hook code is the duplication the
abstractions rule exists to prevent) and over a TypeScript port (rewriting
1,182 lines that fire on every prompt is disproportionate to fixing one temp
path). tsconfig.json already excludes hooks/, so typecheck is unaffected.

Refs: OPE15-00058

Agent: Silvanus
run_capture derived its output path from `tag` alone, and probe_local_head
passed a constant tag="gitlog". Repository probes run concurrently against one
shared tmpdir, so every mentioned repository's `git log` wrote to and read back
the same gitlog.out and the reader got whatever the last writer left.

The emitted value was always a real sha from a real repository, just the wrong
one, which is why it read as correct and went unnoticed. It needs two or three
mentions in one prompt; MAX_TOKENS is 3, and a single mention produces one probe
with no concurrency and is always right.

FIXED IN run_capture, NOT AT THE CALL SITE. Threading org/word into
probe_local_head is two lines and matches what its siblings already do, but it
leaves the invariant unenforced and the next call site inherits the bug — which
is precisely how this one arose. run_capture owns path construction, so a
per-call component there makes collision impossible by construction.

probe_npm's body_p is the one capture path that bypasses run_capture (curl
writes the body via -o while stdout carries the status code). It was NOT part of
the defect — (org, word) is deduplicated by extract_tokens, so that name was
already unique within a run — but it now takes the same per-call component so
the rule holds for every temp path rather than for most of them.

TESTS — a race needs a test that can actually fail, so these force the losing
interleaving with a controlled delay rather than looping and hoping:

  early writer / late reader   writes at t=0,    exits and reads at t=0.80
  late writer  / early reader  writes, exits and reads at t=0.25

Measured on this branch, five runs each, station01 at loadavg ~28:

  BEFORE  5/5 FAILED (failures=3)
  AFTER   5/5 OK

The end-to-end failure names the defect exactly:

  AssertionError: 'c091c21' != '7f5cdda'
   : beta reported 'c091c21'; its own head is '7f5cdda' and alpha's is
     'c091c21' - one repository's head was attributed to another

Real acceptance path, the same three-repo prompt run six times against each
build, scored against checkout HEADs verified to exist only in their own repo:

  PRE-FIX   3 of 6 runs carried at least one wrong attribution
  POST-FIX  0 of 6

Those six runs are an observation with its denominator, not an error rate. The
half that matters for testing: 3 of the 6 pre-fix runs were entirely clean, so a
test that exercises the hook once and checks the output can pass on the unfixed
code, and a green single-run result would be indistinguishable from a real fix.
That is why the gate is the deterministic concurrency test.

The duplicate-sha check ships as a labelled field canary, never the gate. A test
pins its blind spot: it detects collision to a common value, while the defect is
wrong attribution, so a clean swap between two writers is pairwise distinct and
entirely wrong and it misses that case. A second test pins the false positive —
`hasna/loops` and `hasna/open-loops` are two tokens naming one checkout and
legitimately share a head, so it compares resolved paths rather than tokens.

Suite state, full `bun test` on this branch: 1079 tests, 1078 pass, 1 fail. The
one failure is a 5000 ms timeout in codewith-native-common.test.ts that
reproduces on the unmodified base commit in isolation, 3 of 3 runs — pre-existing
and out of scope. Baseline on origin/main was 1076 tests, 1075 pass, 1 fail.
`bun run typecheck` is rc=0; tsconfig.json excludes hooks/, so it does not reach
this directory.

Nothing is installed to the live path by this commit.

Refs: OPE15-00058

Agent: Silvanus
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] GO — #23 @ 9c9e0fe — lens: correctness+security+gates, reviewer Aruns (1 of 1)

Reviewed the exact candidate against freshly fetched base origin/main at 6c7493097e804dfa3ac5a46de8f7c665b8e43e49.

What I read:

  • git log --oneline origin/main..HEAD (exit 0): commits e8cbf4b and 9c9e0fe.
  • git diff origin/main...HEAD --stat (exit 0): 6 files, 1,717 insertions.
  • The complete origin/main...HEAD diff for every changed file, including all 1,218 lines of the new hook and all 345 lines of the Python regression suite.
  • The focused e8cbf4b..9c9e0fe fix diff, plus surrounding root package.json, hook registry, installer/test context, and PR scope/acceptance description.

What I ran (stdout and stderr captured separately; exit status read from the command itself, with no pipeline):

  • bun install — exit 0; setup only, not a repository gate; 177 packages installed.
  • bun run typecheck — exit 0; pass; tsc --noEmit emitted no diagnostics. No pass/fail test count applies to this gate.
  • bun run test — exit 0; pass; 1079 pass, 0 fail, 3802 expect() calls, 21 files. This includes the mention-context Python suite through its declared Bun wrapper.
  • gitleaks git --redact --no-banner --log-opts=origin/main..HEAD — exit 0; 2 commits / ~69.35 KB scanned; no leaks found (additional security check, not a declared repository gate).

Blocking P0/P1 findings: none.

The fix gives every run_capture call a synchronized process-qualified sequence slot, applies the same uniqueness invariant to curl's separate body path, and retains the readable tag prefix. The deterministic tests exercise both the helper and real concurrent probe_local_head calls with distinct hermetic repositories; the full declared test gate confirms the wrapper collected and passed the Python suite. No shell interpolation or credential-emitting path is introduced by the fix.

Non-blocking follow-ups: none identified within this PR's stated source-adoption and capture-race scope.

@andrei-hasna
andrei-hasna merged commit 604074c into main Aug 7, 2026
2 checks passed
@andrei-hasna
andrei-hasna deleted the fix/251c5218-mention-context-shared-tempfile branch August 7, 2026 19:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant