Class: proxy-fallback-session-id-millisecond-dedupe-collision
Class: proxy-fallback-session-id-millisecond-dedupe-collision
Repo: coding-hermes/task-router @ 85e8a61
Files: scripts/router_server.py (identity), tests/test_proxy_metering.py (regression pin)
router_server.py mints the session id for a request that declares no x-router-session from a millisecond timestamp only:
session_id = (f'{source_system}:{declared_session}' if declared_session
else f'{source_system}-{int(time.time() * 1000)}') # <-- not unique
router_outcomes.append_row_fast deliberately dedupes on (source_system, session_id, model) against a bounded tail, as a re-POST guard:
key = (row.get('source_system'), row.get('session_id'), row.get('model'))
...
if (prev.get('source_system'), prev.get('session_id'), prev.get('model')) == key:
return False, 'duplicate (same source_system/session_id/model in tail)'
So two distinct no-key requests answered in the same millisecond share the identity tuple, and the second row is silently dropped — its tokens, cost and steps vanish. The dedupe is correct; the identity was not unique. This is the same hazard class as fake-zero pricing: unknown spend silently becomes zero. It is load-dependent, so a green rerun does not prove it fixed.
Deterministic repro on the live tree (frozen clock, the test's own fixture path) before the fix:
FROZEN-CLOCK rows: 1
session_id= router-proxy-1790478882356 provider= p model= m
VERDICT: COLLISION (flake confirmed)
Keep the human-readable ms prefix, add a per-process token (pid + random, so two worker processes cannot collide) and a monotonic per-process sequence (so two threads in one process cannot). A lock makes the sequence read atomic under ThreadingHTTPServer. All three fallback sites now use the helper; append_row_fast's dedupe is untouched.
@@ imports
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+import itertools
import json
...
from urllib.parse import parse_qs, urlparse
+import uuid
@@ after MAX_BODY_BYTES
+# TR-192: fallback identity for a request that declares NO session.
+# Milliseconds alone are NOT a unique key ... (see root cause)
+_FALLBACK_SESSION_PROC = f"{os.getpid()}-{uuid.uuid4().hex[:8]}"
+_FALLBACK_SESSION_SEQ = itertools.count(1)
+_FALLBACK_SESSION_LOCK = threading.Lock()
+
+
+def _fallback_session_id(source_system):
+ """Collision-proof session id for a request with no declared session."""
+ with _FALLBACK_SESSION_LOCK:
+ seq = next(_FALLBACK_SESSION_SEQ)
+ return (f"{source_system}-{int(time.time() * 1000)}"
+ f"-{_FALLBACK_SESSION_PROC}-{seq}")
@@ _proxy_record (last-resort fallback)
- 'session_id': session_id or f'proxy-{time.time()}',
+ 'session_id': session_id or _fallback_session_id(source),
@@ proxy_chat overload exit
- session_id = hdrs.get('x-router-session') or f'router-proxy-{int(time.time() * 1000)}'
+ session_id = hdrs.get('x-router-session') or _fallback_session_id('router-proxy')
@@ _proxy_chat_inner (primary fallback)
session_id = (f'{source_system}:{declared_session}' if declared_session
- else f'{source_system}-{int(time.time() * 1000)}')
+ else _fallback_session_id(source_system))
Declared sessions are unchanged ({source_system}:{declared_session} + accumulate_row), so one session still collapses to one growing row.
Apply:
cd /path/to/task-router
$EDITOR scripts/router_server.py # apply the hunks above
python3 -m pytest tests/test_proxy_metering.py -q
Added to tests/test_proxy_metering.py; it freezes the clock so the collision is reproducible on any machine regardless of load:
def test_fallback_session_ids_survive_the_same_millisecond(monkeypatch, tmp_path):
store = tmp_path / 'outcomes.jsonl'
monkeypatch.setenv('ROUTING_OUTCOMES_FILE', str(store))
import importlib
import router_outcomes as ro
importlib.reload(ro)
monkeypatch.setattr(rsrv, 'router_outcomes', ro, raising=False)
_chain(monkeypatch, [{'hop': 1, 'provider': 'p', 'model': 'm', 'usd_1m': 1.0}])
monkeypatch.setattr(rsrv, '_subprocess_text', lambda *a, **k: '')
monkeypatch.setattr(rsrv.time, 'time', lambda: 1790478882.356) # freeze the ms
up = lambda p, b, h: (200, {'usage': {'prompt_tokens': 10, 'completion_tokens': 10}})
for _ in range(2):
rsrv.proxy_chat('/v1/chat/completions', {'model': 'auto', 'messages': []}, {}, upstream=up)
rows = [json.loads(l) for l in open(store) if l.strip()]
assert len(rows) == 2 # FAILS pre-fix with 1
assert rows[0]['session_id'] != rows[1]['session_id']
assert all(r['session_id'].startswith('router-proxy-') for r in rows)
85e8a61 + patch)| Check | Command | Result |
|---|---|---|
| Frozen-clock repro | python3 /tmp/repro_collision.py |
rows: 2, distinct ids → VERDICT: OK |
| Target test | pytest tests/test_proxy_metering.py::test_without_a_declared_session_each_request_stands_alone -q |
passed |
| Regression test | pytest tests/test_proxy_metering.py::test_fallback_session_ids_survive_the_same_millisecond -q |
passed (fails on unpatched base) |
| Thread/process uniqueness | 8 threads × 2000 ids at one frozen ms; subprocess id | 16000/16000 unique; cross-process token distinct |
| Proxy/outcome suite | pytest tests/test_proxy_*.py tests/test_outcome*.py tests/test_ui_data.py -q |
320 passed, 2 pre-existing env failures |
Post-fix repro output:
FROZEN-CLOCK rows: 2
session_id= router-proxy-1790478882356-120-ff202849-1 provider= p model= m
session_id= router-proxy-1790478882356-120-ff202849-2 provider= p model= m
VERDICT: OK (2 distinct rows)
The two pre-existing failures (test_profile_signature_is_declared_category_levels, test_proxy_classify.py::test_categories_are_data_driven_not_hardcoded) are registry/data-dependent and fail identically on the unmodified commit — unrelated to this change.
append_row_fast's dedupe: it legitimately discards real re-POSTs and preserves intent. The fix is purely in identity generation.router-proxy-<ms>-<pid>-<rand>-<seq>), so existing UI/Grep flows keep working.body['user'] fallback at router_server.py:3019 sets declared_session after session_id/parent_session_id are computed, so it cannot currently associate a request. Fixing it would change accumulation semantics and deserves its own ticket/test.# Evidence - Problem class: proxy-fallback-session-id-millisecond-dedupe-collision - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-27T03:25:59.975Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "CI red on a fast GitHub runner: a ledger-dedupe collision on a millisecond-resolution fallback session id.\n\nSYMPTOM\nCI run 36290613927 (coding-hermes/task-router, main @ 85e8a61) failed the \"Full test suite\" step:\n tests/test_proxy_metering.py::test_without_a_declared_session_each_request_stands_alone\n AssertionError: assert (1 == 2)\n 1 failed, 1208 passed, 2 skipped in 486.45s\nThe same tree passed 1210 passed / 1 skipped locally in the same tick, and the test passes 10/10 in isolation locally.\n\nROOT CAUSE\nrouter_server.py builds the fallback session id for a request that declares NO x-router-session as:\n session_id = f'{source_system}-{int(time.time() * 1000)}'\ni.e. MILLISECOND resolution. The test issues two proxy_chat calls in a tight loop; on a fast CI runner both land inside the same millisecond, so both rows carry the SAME session_id and therefore the same identity tuple (source_system, session_id, model). router_outcomes.append_row_fast dedupes on exactly that key against a bounded tail of the store (\"the same (source_system, session_id, model) inside the recent tail is treated as a re-POST and skipped\"), so the second row is dropped and the ledger holds ONE row \u2014 precisely the observed `len(rows) == 1`.\n\nThat dedupe is CORRECT and deliberate (it is the re-POST guard). The bug is the IDENTITY, not the dedupe: milliseconds are not a unique key.\n\nVERIFICATION (deterministic, on the live tree, no CI needed)\nFreeze time.time() to a single value (1790478882.356) for router_server and run the test's own fixture path (chain stub, upstream stub, ROUTING_OUTCOMES_FILE in a tmpdir) with the same two-call loop:\n FROZEN-CLOCK rows: 1\n session_id= router-proxy-1790478882356 provider= p model= m\n VERDICT: COLLISION (flake confirmed)\nWith the real clock the identical code produces 2 rows and the test passes 10/10 \u2014 so the two-call loop is only ever a hair away from a ms boundary. This makes the failure load-dependent and self-healing on a rerun, which is why an ordinary rerun green does NOT prove it gone.\n\nWHY IT MATTERS BEYOND CI (real runtime effect, same class as fake-zero pricing)\nAny two real no-key proxied requests answered in the same millisecond collapse into one metered row: the second request's tokens, cost and steps silently vanish from the ledger. Unknown-as-zero is the exact hazard this ledger exists to prevent, so a flaky test here is a cheap detector for a real metering hole.\n\nFIX DIRECTION (not applied in this tick)\nMake the fallback identity collision-proof rather than widening the dedupe window, e.g. keep the ms prefix for human readability and add a per-process monotonic counter or a short uuid suffix:\n f'{source_system}-{int(time.time()*1000)}-{next(counter)}'\nDo NOT loosen append_row_fast's dedupe: it legitimately discards real re-POSTs.\n\nSTACK TRACE / EVIDENCE\nCI: https://github.com/coding-hermes/task-router/actions/runs/36290613927 (job 108539844161)\nRow: .coding-hermes/board/tasks.jsonl INT-CI-20260927-01\nFailing assert source: tests/test_proxy_metering.py:245 -> assert len(rows) == 2 and all(r['parent_session_id'] is None for r in rows)", "environment": "coding-hermes/task-router @ ~/task-router; board venv ~/.hermes/venvs/board/bin/python3; GitHub Actions ubuntu-latest python3.11", "language": "python", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "proxy-fallback-session-id-millisecond-dedupe-collision", "provider": "openrouter", "solved_at": "2026-09-27T03:25:59.975Z", "version": "main@85e8a61"}Class: proxy-fallback-session-id-millisecond-dedupe-collision
Repo: coding-hermes/task-router @ 85e8a61
Files: scripts/router_server.py (identity), tests/test_proxy_metering.py (regression pin)
router_server.py mints the session id for a request that declares no x-router-session from a millisecond timestamp only:
session_id = (f'{source_system}:{declared_session}' if declared_session
else f'{source_system}-{int(time.time() * 1000)}') # <-- not unique
router_outcomes.append_row_fast deliberately dedupes on (source_system, session_id, model) against a bounded tail, as a re-POST guard:
key = (row.get('source_system'), row.get('session_id'), row.get('model'))
...
if (prev.get('source_system'), prev.get('session_id'), prev.get('model')) == key:
return False, 'duplicate (same source_system/session_id/model in tail)'
So two distinct no-key requests answered in the same millisecond share the identity tuple, and the second row is silently dropped — its tokens, cost and steps vanish. The dedupe is correct; the identity was not unique. This is the same hazard class as fake-zero pricing: unknown spend silently becomes zero. It is load-dependent, so a green rerun does not prove it fixed.
Deterministic repro on the live tree (frozen clock, the test's own fixture path) before the fix:
FROZEN-CLOCK rows: 1
session_id= router-proxy-1790478882356 provider= p model= m
VERDICT: COLLISION (flake confirmed)
Keep the human-readable ms prefix, add a per-process token (pid + random, so two worker processes cannot collide) and a monotonic per-process sequence (so two threads in one process cannot). A lock makes the sequence read atomic under ThreadingHTTPServer. All three fallback sites now use the helper; append_row_fast's dedupe is untouched.
@@ imports
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+import itertools
import json
...
from urllib.parse import parse_qs, urlparse
+import uuid
@@ after MAX_BODY_BYTES
+# TR-192: fallback identity for a request that declares NO session.
+# Milliseconds alone are NOT a unique key ... (see root cause)
+_FALLBACK_SESSION_PROC = f"{os.getpid()}-{uuid.uuid4().hex[:8]}"
+_FALLBACK_SESSION_SEQ = itertools.count(1)
+_FALLBACK_SESSION_LOCK = threading.Lock()
+
+
+def _fallback_session_id(source_system):
+ """Collision-proof session id for a request with no declared session."""
+ with _FALLBACK_SESSION_LOCK:
+ seq = next(_FALLBACK_SESSION_SEQ)
+ return (f"{source_system}-{int(time.time() * 1000)}"
+ f"-{_FALLBACK_SESSION_PROC}-{seq}")
@@ _proxy_record (last-resort fallback)
- 'session_id': session_id or f'proxy-{time.time()}',
+ 'session_id': session_id or _fallback_session_id(source),
@@ proxy_chat overload exit
- session_id = hdrs.get('x-router-session') or f'router-proxy-{int(time.time() * 1000)}'
+ session_id = hdrs.get('x-router-session') or _fallback_session_id('router-proxy')
@@ _proxy_chat_inner (primary fallback)
session_id = (f'{source_system}:{declared_session}' if declared_session
- else f'{source_system}-{int(time.time() * 1000)}')
+ else _fallback_session_id(source_system))
Declared sessions are unchanged ({source_system}:{declared_session} + accumulate_row), so one session still collapses to one growing row.
Apply:
cd /path/to/task-router
$EDITOR scripts/router_server.py # apply the hunks above
python3 -m pytest tests/test_proxy_metering.py -q
Added to tests/test_proxy_metering.py; it freezes the clock so the collision is reproducible on any machine regardless of load:
def test_fallback_session_ids_survive_the_same_millisecond(monkeypatch, tmp_path):
store = tmp_path / 'outcomes.jsonl'
monkeypatch.setenv('ROUTING_OUTCOMES_FILE', str(store))
import importlib
import router_outcomes as ro
importlib.reload(ro)
monkeypatch.setattr(rsrv, 'router_outcomes', ro, raising=False)
_chain(monkeypatch, [{'hop': 1, 'provider': 'p', 'model': 'm', 'usd_1m': 1.0}])
monkeypatch.setattr(rsrv, '_subprocess_text', lambda *a, **k: '')
monkeypatch.setattr(rsrv.time, 'time', lambda: 1790478882.356) # freeze the ms
up = lambda p, b, h: (200, {'usage': {'prompt_tokens': 10, 'completion_tokens': 10}})
for _ in range(2):
rsrv.proxy_chat('/v1/chat/completions', {'model': 'auto', 'messages': []}, {}, upstream=up)
rows = [json.loads(l) for l in open(store) if l.strip()]
assert len(rows) == 2 # FAILS pre-fix with 1
assert rows[0]['session_id'] != rows[1]['session_id']
assert all(r['session_id'].startswith('router-proxy-') for r in rows)
85e8a61 + patch)| Check | Command | Result |
|---|---|---|
| Frozen-clock repro | python3 /tmp/repro_collision.py |
rows: 2, distinct ids → VERDICT: OK |
| Target test | pytest tests/test_proxy_metering.py::test_without_a_declared_session_each_request_stands_alone -q |
passed |
| Regression test | pytest tests/test_proxy_metering.py::test_fallback_session_ids_survive_the_same_millisecond -q |
passed (fails on unpatched base) |
| Thread/process uniqueness | 8 threads × 2000 ids at one frozen ms; subprocess id | 16000/16000 unique; cross-process token distinct |
| Proxy/outcome suite | pytest tests/test_proxy_*.py tests/test_outcome*.py tests/test_ui_data.py -q |
320 passed, 2 pre-existing env failures |
Post-fix repro output:
FROZEN-CLOCK rows: 2
session_id= router-proxy-1790478882356-120-ff202849-1 provider= p model= m
session_id= router-proxy-1790478882356-120-ff202849-2 provider= p model= m
VERDICT: OK (2 distinct rows)
The two pre-existing failures (test_profile_signature_is_declared_category_levels, test_proxy_classify.py::test_categories_are_data_driven_not_hardcoded) are registry/data-dependent and fail identically on the unmodified commit — unrelated to this change.
append_row_fast's dedupe: it legitimately discards real re-POSTs and preserves intent. The fix is purely in identity generation.router-proxy-<ms>-<pid>-<rand>-<seq>), so existing UI/Grep flows keep working.body['user'] fallback at router_server.py:3019 sets declared_session after session_id/parent_session_id are computed, so it cannot currently associate a request. Fixing it would change accumulation semantics and deserves its own ticket/test.# Evidence - Problem class: proxy-fallback-session-id-millisecond-dedupe-collision - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-27T03:25:59.975Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "CI red on a fast GitHub runner: a ledger-dedupe collision on a millisecond-resolution fallback session id.\n\nSYMPTOM\nCI run 36290613927 (coding-hermes/task-router, main @ 85e8a61) failed the \"Full test suite\" step:\n tests/test_proxy_metering.py::test_without_a_declared_session_each_request_stands_alone\n AssertionError: assert (1 == 2)\n 1 failed, 1208 passed, 2 skipped in 486.45s\nThe same tree passed 1210 passed / 1 skipped locally in the same tick, and the test passes 10/10 in isolation locally.\n\nROOT CAUSE\nrouter_server.py builds the fallback session id for a request that declares NO x-router-session as:\n session_id = f'{source_system}-{int(time.time() * 1000)}'\ni.e. MILLISECOND resolution. The test issues two proxy_chat calls in a tight loop; on a fast CI runner both land inside the same millisecond, so both rows carry the SAME session_id and therefore the same identity tuple (source_system, session_id, model). router_outcomes.append_row_fast dedupes on exactly that key against a bounded tail of the store (\"the same (source_system, session_id, model) inside the recent tail is treated as a re-POST and skipped\"), so the second row is dropped and the ledger holds ONE row \u2014 precisely the observed `len(rows) == 1`.\n\nThat dedupe is CORRECT and deliberate (it is the re-POST guard). The bug is the IDENTITY, not the dedupe: milliseconds are not a unique key.\n\nVERIFICATION (deterministic, on the live tree, no CI needed)\nFreeze time.time() to a single value (1790478882.356) for router_server and run the test's own fixture path (chain stub, upstream stub, ROUTING_OUTCOMES_FILE in a tmpdir) with the same two-call loop:\n FROZEN-CLOCK rows: 1\n session_id= router-proxy-1790478882356 provider= p model= m\n VERDICT: COLLISION (flake confirmed)\nWith the real clock the identical code produces 2 rows and the test passes 10/10 \u2014 so the two-call loop is only ever a hair away from a ms boundary. This makes the failure load-dependent and self-healing on a rerun, which is why an ordinary rerun green does NOT prove it gone.\n\nWHY IT MATTERS BEYOND CI (real runtime effect, same class as fake-zero pricing)\nAny two real no-key proxied requests answered in the same millisecond collapse into one metered row: the second request's tokens, cost and steps silently vanish from the ledger. Unknown-as-zero is the exact hazard this ledger exists to prevent, so a flaky test here is a cheap detector for a real metering hole.\n\nFIX DIRECTION (not applied in this tick)\nMake the fallback identity collision-proof rather than widening the dedupe window, e.g. keep the ms prefix for human readability and add a per-process monotonic counter or a short uuid suffix:\n f'{source_system}-{int(time.time()*1000)}-{next(counter)}'\nDo NOT loosen append_row_fast's dedupe: it legitimately discards real re-POSTs.\n\nSTACK TRACE / EVIDENCE\nCI: https://github.com/coding-hermes/task-router/actions/runs/36290613927 (job 108539844161)\nRow: .coding-hermes/board/tasks.jsonl INT-CI-20260927-01\nFailing assert source: tests/test_proxy_metering.py:245 -> assert len(rows) == 2 and all(r['parent_session_id'] is None for r in rows)", "environment": "coding-hermes/task-router @ ~/task-router; board venv ~/.hermes/venvs/board/bin/python3; GitHub Actions ubuntu-latest python3.11", "language": "python", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "proxy-fallback-session-id-millisecond-dedupe-collision", "provider": "openrouter", "solved_at": "2026-09-27T03:25:59.975Z", "version": "main@85e8a61"}