Skip to content

fix(watcher): carry Codex turn_context author model across realtime reads (Refs #655, stacked on #1639) - #1651

Draft
EtanHey wants to merge 3 commits into
claude/orc-final-credit-batch-z2uwd0from
claude/draft-pr-review-c7c8n9
Draft

EtanHey wants to merge 3 commits into
claude/orc-final-credit-batch-z2uwd0from
claude/draft-pr-review-c7c8n9

Conversation

@EtanHey

@EtanHey EtanHey commented Nov 5, 2026 •

Copy link
Copy Markdown
Owner

Draft. Stacked on PartA #1639 (claude/orc-final-credit-batch-z2uwd0 @ a03bca7), not main. Do not merge, mark ready, or queue it. The owning lead reviews it.

Task marker: cloud-credit-20261105-brainlayer-hammer-codex-author-state. Refs #655. This is not a Fixes: PartA has to land first, and the gaps listed under Limitations are still open.

What was broken

PartA (a03bca7) records the per-turn author model in the batch Codex parser and in Claude's message.model. The realtime watcher got none of it for Codex:

  • normalize_provider_entry returns None for turn_context and session_meta, so the model and provider were thrown away before classification. On a03bca7, no realtime Codex chunk carries model or model_provider. The new tests confirm this (old run below).
  • A one-line fix that just remembered "the last model seen" would still be wrong. After a restart, a replay of confirmed offsets, a rewind, a replaced inode, or quarantined bytes, the reader starts somewhere the remembered value does not describe. It would then stamp a stale model on the turn, or no model at all.

What changed (watcher normalization / per-file context only)

  • src/brainlayer/watcher.py
    • CodexTurnContext(inode, offset, model, model_provider) is stored on each JSONLTailer, so every file has its own. It is trusted only at the exact (inode, offset) it describes.
    • _annotate_codex_author_context runs in poll_once before normalization. If the read starts anywhere other than the stored (inode, offset) (restart, replay after a failed read, rewind to 0, new inode, skipped quarantine bytes), the context is rebuilt by scan_codex_turn_context from the file's own bytes before the read start. The scan only parses lines that mention turn_context or session_meta. It returns unknown if the inode it opened is not the one being read. An unparseable turn_context resets the model to unknown instead of keeping the older one.
    • fold_codex_turn_context follows PartA's batch semantics. Every turn_context replaces the model, and an absent model or <synthetic> becomes unknown (via PartA's transcript_author_model). session_meta sets the provider.
    • The stamp (_author_model, _author_model_source, _author_model_provider) is written onto the entry itself. Deferred entries (T3 linkage unresolved) and retried entries therefore keep the author they were read with. Nothing recomputes it later.
    • _normalize_lines puts message.model only on assistant entries. User entries get the provider and no model.
  • src/brainlayer/watcher_bridge.py (+6 lines): sets metadata.model_source = "codex_turn_context" only when the chunk has a model, and sets metadata.model_provider when it is known. The Claude path is unchanged: Claude chunks get model from PartA and no model_source.
  • The offset registry format is unchanged. No new keys, no migration. A legacy {offset, inode, mtime} entry with no generation resumes correctly, because the context comes from the file and not from the registry.
  • Not touched: PartA classify.py / ingest/codex.py, and the PartB Swift caller_model work (feat(brainbar): optional self-reported caller_model on served brain_store (Refs #655, Part B) #1643).

Metadata contract (realtime Codex)

chunk model model_source model_provider
assistant after a usable turn_context the transcript's model, verbatim (stripped) codex_turn_context from session_meta if present
assistant with no / unusable / unparseable turn_context before it absent (unknown) absent from session_meta if present
user absent absent from session_meta if present

One difference from PartA's batch parser: batch defaults a missing session_meta.model_provider to "openai". Realtime leaves it unknown. Unknown stays unknown, so the lead should decide whether batch keeps that default.

Proof (synthetic fixtures only; Linux cloud container)

Requested base: main@d6b82f04. Actual base: PartA head a03bca748a74dd2836b4b613a1fd21d8e15d888b, fetched inside the cloud session; main is its parent. Branch: claude/draft-pr-review-c7c8n9, head 6479098.

All 11 tests in tests/test_codex_realtime_author_model.py drive the production path: JSONLWatcher.poll_once → indexer → create_flush_callback → a synthetic scratch SQLite DB, or with arbitrated=True the queue JSONL. Restart tests build a fresh JSONLWatcher on the same offsets file.

scenario test
mixed-model turns in one read; user turn not attributed test_mixed_model_turns_in_one_read…
restart mid-file → latest model, not the first test_resume_mid_file_after_restart…
legacy offset entry (no generation) test_resume_from_a_legacy_offset_entry…
replay after a failed read doesn't reuse the end-of-read context test_replay_after_a_failed_read…
truncation rewind drops context from removed bytes test_truncation_rewind…
replaced inode (file grew, so only the inode tells) test_replaced_inode…
per-file isolation, across restart test_context_is_per_file
<synthetic> turn_context → unknown, not previous test_turn_context_without_a_usable_model…
scan: unparseable turn_context → unknown; wrong inode → unknown test_prefix_scan_* (2)
arbitrated queue carries the same attribution test_arbitrated_queue_carries_the_same_attribution

Old vs new:

  • a03bca7 source with the new tests: 10 failed, 1 passed. The one that passes is the truncation test, because old code never attributes at all. The always-reuse mutant below fails it.
  • This branch: 11 passed.

Mutants (each applied alone, then reverted):

mutant result
always reuse the stored context (no offset/inode continuity check) killed (replay, truncation)
no prefix scan (an unknown context on resume) killed (5 tests)
first turn_context wins killed (5 tests)
keep the old model when a turn_context is unparseable killed (scan test)
scan skips its inode check killed (scan test)
drop the context's inode comparison, keep only the offset survived. This one is equivalent today: an inode change always builds a new JSONLTailer (offset 0, empty context) in _ensure_tailer, so the comparison is defence in depth.

Neighbouring suites: test_codex_realtime_author_model, test_author_model_provenance, test_jsonl_watcher, test_watcher, test_watcher_bridge, test_t3_app_provenance, test_ingest_denylist: 277 passed. ruff check and ruff format --check are clean on the touched files. Scoped scripts/run_tests.sh (BRAINLAYER_PREPUSH_SCOPE=changed-only, the 3 changed files): gate passed, exit 0. That covers the unit suite (176 passed: the new tests, test_jsonl_watcher 112, test_watcher_bridge 53), MCP registration (3), isolated eval/hook routing (40), bun, and the FTS5 shell regression. A first attempt timed out at my 900 s cap because I passed the files space-separated instead of comma-separated; it measured nothing.

Limitations / UNRUN

  • Not installed, not deployed, not live. Nothing ran on a Mac or against a real DB or real sessions. The full 4.3k-test suite was not run (the scoped run above was). There is no retrieval proof: no test runs a brain_search that filters or ranks by model. The only proof is that the metadata is persisted, in the DB row and in the queue event.
  • Arbitrated proof covers the queue event metadata. It does not include a drain-to-DB step for that event.
  • The prefix scan is a one-time cost per file per process: it runs on the first read of a resumed Codex file, and again after any replay, rewind, or quarantine skip. It is a line-by-line read with a substring filter, and only matching lines are JSON-parsed. It has not been measured on large real rollouts.
  • An in-place rewrite of the same inode that ends up larger than the stored offset is not detectable by the watcher. The bytes and the context are both stale there; that was already true before this change.
  • Size: +123 source lines and +309 test lines (432 handwritten in total, about 8% over the 400-line budget). All of the overrun is test fixtures and scenarios; the source change is small.
  • Batch/realtime provider-default divergence: see the note under the metadata table.

Risks

  • Codex reads now open the file a second time when the context has to be rebuilt. That read is read-only and uses no locks. It does not touch the DB, the registry, or checkpoint logic, and offsets and confirmation are unchanged.
  • The new entry keys ride the existing indexer/flush/deferral path. Unknown keys are ignored downstream.

Handoff → @orc (BrainLayer lead snoozed)


Generated by Claude Code

Note

Track Codex turn-context author model and provider across realtime watcher reads

Adds CodexTurnContext to track the active model and provider per Codex session file in watcher.py.

  • turn-context records update the model; session-metadata records update the provider. New lines are annotated with the context in effect at their position, and each tailer caches its context at the current offset.
  • On restart, legacy offsets, or cache mismatches, scan_codex_turn_context rebuilds the context from the file prefix, with inode and record-boundary checks. Malformed turn-context lines yield an unknown model.
  • poll_once annotates new Codex lines before normalization, and _normalize_lines carries the model, model source, and provider onto entries. Chunks can now store model_source and model_provider in metadata via watcher_bridge.py.
  • Covers resume, replay after failed reads, truncation rewind, file replacement, and per-file isolation in test_codex_realtime_author_model.py.
  • Behavioral Change: assistant entries without a usable turn-context model now store no model instead of a stale one; check _annotate_codex_author_context and fold_codex_turn_context.

Macroscope summarized 6479098.

…ex chunks (#655)

Claude Code transcripts record message.model on every assistant turn, but
_extract_entry_metadata dropped it, so realtime-watcher and batch-index rows
could not answer 'which model wrote this?'. Read it into chunk metadata
['model'] for assistant entries only, verbatim, and leave it unknown when it
is absent, non-string, malformed, or Claude Code's '<synthetic>' placeholder.

The Codex parser, cited as the working path, actually stored model_provider
('openai') under 'model' and kept the first value for the whole session.
It now takes each turn_context model as the author of the following turn,
records model_provider separately, and stops attributing user and tool
chunks to a model.

Authoring provenance stays distinct from enrichment_model/enrichment_backend.

Refs #655

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3NoZDshC2wAT1TQqUTtFa
…eads (#655)

The realtime watcher dropped Codex turn_context records, so no Codex
realtime chunk ever carried an author model, and a resumed reader would
have nothing to recover it from. Keep a per-file author context on the
tailer, valid only at the exact (inode, offset) it describes; any other
read start (restart, replay, rewind, replaced inode) rebuilds it from the
file prefix. The stamp rides on the entry, so deferred and retried
entries keep their author. Assistant chunks get metadata model and
model_source=codex_turn_context; all Codex chunks get model_provider
from session_meta. Unknown stays unknown. Offset registry format is
unchanged.

Refs #655

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3NoZFsx8C4pmVN9vJ8gUZ
… under old code (#655)

Import the prefix-scan helper inside its two unit tests so the
watcher-path tests run, and fail on attribution, against the PartA
source instead of failing at import. Apply ruff format.

Refs #655

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3NoZFsx8C4pmVN9vJ8gUZ
@coderabbitai

coderabbitai Bot commented Nov 5, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@deepsource-io

deepsource-io Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

DeepSource Code Review

We reviewed changes in a03bca7...6479098 on this pull request. Below is the summary for the review, and you can see the individual issues we found as inline review comments.

See full review on DeepSource ↗

PR Report Card

Overall Grade   Security  

Reliability  

Complexity  

Hygiene  

Code Review Summary

Analyzer Status Updated (UTC) Details
Python Nov 5, 2026 5:10a.m. Review ↗
Swift Nov 5, 2026 5:10a.m. Review ↗
JavaScript Nov 5, 2026 5:10a.m. Review ↗
Shell Nov 5, 2026 5:10a.m. Review ↗
Secrets Nov 5, 2026 5:10a.m. Review ↗

Important

AI Review is run only on demand for your team. We're only showing results of static analysis review right now. To trigger AI Review, comment @deepsourcebot review on this thread.

Comment thread src/brainlayer/watcher.py
if end_offset <= 0:
return context
try:
with open(filepath, "rb") as handle:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

External variable 'filepath' used in file path


Python's open() function can take in a relative or absolute path and read its file contents. If a user is provided direct access to the path that is opened, it can have serious security risks.

os.replace(replacement, source)
_drain(watcher)

new_row = next(meta for content, meta in _rows(tmp_path).items() if content.startswith(_say(1)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to `next()` should be wrapped in `try-except`


Calls to next() should be inside try-except block.

@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

BrainLayer ratchet

Every Value below was measured by this run. A row this machine cannot measure says n/a — <reason> instead of a number; baselines in Notes name their own machine, method and date and were not measured here.

Hosted success gives no verdict on mapped bytes, search p50/p95, idle CPU, hotlane backlog CPU, or fallback replay debt.

Row Status Value (measured by this run) Method Notes
commit provenance 🟢 GREEN measured 647909895e69 == PR head · checkout 647909895e69 commit graph + live PR head · in-process · runner Which commit this whole table is about. On a pull_request event the checkout is GitHub's synthetic merge ref, whose sha is not on the PR — #759's table printed 13fa724278bf while that PR's head was 4632f979 — so this row names the PR-head parent instead, the sha a reviewer can actually see. The comparison sha is read live from repos/{owner}/{repo}/pulls/{n} when the table is collected, not taken from the event payload, because the payload cannot know the run has been overtaken. Residual window, stated rather than papered over: a push landing between that read and the comment being posted is not caught here — the run for that push refreshes the table.
baseline attestation 🟢 GREEN baseline f421d1a7c5e6 matches the main attestation (run 41876229562 · main d6b82f04f6ba · 2026-11-04T17:18:27Z) main attestation artifact via Actions API · in-process · runner What every comparison is measured AGAINST, and who says so. The baseline fields of tests/fixtures/sprint_gate/corpus.json (queries, latency_baseline_ms, thresholds) are compared to the ratchet-attestation artifact of the latest successful push or (no-input) workflow_dispatch run of ratchet-attest.yml on main, fetched through the Actions API — a PR run cannot write to another run's artifacts. A field that differs is RED unless that main run measured the new value. The calibrated socket collector can license p50/p95; every absent measured path stays locked, so missing collection never passes as permission for a hand edit. Boundary: the comparator is this PR's checkout of ci_ratchet_table.py, diff-reviewable, not tamper-proof.
provenance 🟢 GREEN stamped 647909895e69 == HEAD, tree clean wheel stamp · in-process · runner Sha half of #749 keg-mode provenance: a keg built from this wheel can answer __build_sha__. The helper-age and served-process predicates need a running BrainBar and are measured only by scripts/sprint_gate.py on an installed Mac. The sha here is the checkout's — the explicit PR head in ratchet.yml — because that is what publish.yml stamps at release time; the PR-head sha this table describes is the one in commit provenance above.
fallback replay debt ⚪ n/a n/a — no fallback queue on this machine: the pending memories live in ~/Gits/*/docs.local/decisions, and docs.local/ is gitignored, so a runner checkout has no copy of them to count docs.local walk · machine with the fallback queue local-only; not enforced by hosted CI. intended_brain_store: true with no chunk_id means a memory reached disk and never reached the DB, so it answers no brain_search. Budget: 0. Any pending or unparseable file is a finding, never a band -- 122 of these sat from 2026-06-28 to 2026-09-05 because nothing counted them where a reader would look. Measured by walking the tree, so it is only ever measured on a machine that HAS the tree.
pre-push: delete-only push runs no suite 🟢 GREEN 0 suite starts · exit 0 real checkout hook · delete-only stdin · sentinel suite runner Bug SHA: 44ecbf5ba6f1ada3b965797559eee90c94b45595 (RED replay, 2026-10-06). Fix: #1084; measured at 647909895e6949c689fbe045dbb3391cb458ac89. Ceiling: 0 suite starts; hook must exit 0 and announce the delete-only skip.
mapped bytes ⚪ n/a n/a — no BrainBar daemon at /tmp/brainbar.sock: this row needs the daemon, its hybrid helper and the indexed corpus running together, and no GitHub-hosted runner has them (macOS included) — only a self-hosted Darwin/arm64 runner on an installed Mac would socket · installed Mac local-only; not enforced by hosted CI. Baseline 26.2 GB — installed Mac, socket, 2026-09-03, after R2 drained 15,070 → 0. Up from 16.8 GB because the drain left more vectors mapped under the same cap: the change is the drain, not a leak. Not measured by this run.
search p50/p95 ⚪ n/a n/a — no BrainBar daemon at /tmp/brainbar.sock: this row needs the daemon, its hybrid helper and the indexed corpus running together, and no GitHub-hosted runner has them (macOS included) — only a self-hosted Darwin/arm64 runner on an installed Mac would socket · installed Mac local-only; not enforced by hosted CI. Margin p50: margin unmeasured — 0 of the 5 attested green main runs it needs; no verdict is rendered from fewer. Margin p95: margin unmeasured — 0 of the 5 attested green main runs it needs; no verdict is rendered from fewer. Calibrated on MacBook-Pro.local at 2026-09-01T08:42:22Z under active_sprint_load (tests/fixtures/sprint_gate/corpus.json). Not measured by this run.
idle CPU ⚪ n/a n/a — no BrainBar daemon at /tmp/brainbar.sock: this row needs the daemon, its hybrid helper and the indexed corpus running together, and no GitHub-hosted runner has them (macOS included) — only a self-hosted Darwin/arm64 runner on an installed Mac would ps sampling · installed Mac local-only; not enforced by hosted CI. Ceiling: average CPU < 30% over a 60 s window (resource_budget in scripts/sprint_gate.py), ratified and kept as a hard budget. Margin daemon: margin unmeasured — 0 of the 5 attested green main runs it needs; no verdict is rendered from fewer. Margin helper: margin unmeasured — 0 of the 5 attested green main runs it needs; no verdict is rendered from fewer. Margin watcher: margin unmeasured — 0 of the 5 attested green main runs it needs; no verdict is rendered from fewer. Needs the BrainBar daemon, helper and watcher actually running. Not measured by this run.
hotlane backlog CPU ⚪ n/a n/a — live hotlane CPU evidence requires an installed Mac CPU/wall for backlog-encoding cycles · rolling 600 s · installed Mac local-only; not enforced by hosted CI. Default budget 60% of one core; BRAINLAYER_HOTLANE_CPU_BUDGET_PERCENT overrides. At least 30s of active backlog cycles required; yielded time excluded. Measures installed code.
signature_valid ⚪ n/a n/a — the macOS signature-parity job is trigger-gated and did not run on this PR: it touches no release or signing path (pyproject.toml, scripts/release-*, scripts/brainlayer-version-check.sh, publish.yml, ratchet.yml) and carries no ratchet:signatures label — a GitHub macOS runner bills at ~10× Linux minutes and rebuilds the keg venv from source codesign · installed keg scripts/release-verify-signatures.sh <keg> codesign-verifies every *.so/*.dylib under libexec/venv. The macOS parity job installs the published tap formula (etanhey/layers/brainlayer), so this row measures the release path — formula, published sdist and Homebrew's relocation — and not this PR's tree. Release-time baseline for the same keg on a different machine: 442 valid / 0 invalid — installed Mac (M4 Max), brew --prefix brainlayer 1.5.11, 2026-09-03.
BrainBar renders no enrichment status 🟢 GREEN 19 actual-view captures; active pending-store/replay retained macOS DEBUG binary · AppKit actual views · Vision OCR + icon RGBA comparison · private synthetic state Missing render capability/evidence is RED. Historical data is retained; this is source-build proof, not installed-app proof.
Watcher heartbeat freshness and recovery 🟢 GREEN 13 cases; polls 1 -> 2 private Python producer -> compiled Swift consumer Preserves genuine silence; independent agent stores cannot revive the watcher. Not a load or installed-UI measurement.
maintenance quiesce: services stay down under a reviving watchdog ⚪ n/a n/a — macOS replay trigger-gated: no affected path or ratchet:quiesce label real launchd · GitHub macOS runner · real maintenance + fleet-watchdog Fix: #1076 (bc6d690f4331387485678d0247e19e14e6ef8d97). Ceiling: 0 revivals; supervisor held, both resumed, no hold marker.
no cloud model call reachable anywhere 🟢 GREEN 520 source files; 460 installed imports; 3 native tests; wheel c9650d9b8810; 0 candidate attempts immutable installed wheel (default + dev) + AST source + actual native BrainBar Synthetic private fixtures; SDK absence gated on merged R10b. Exact-head candidate proof, not deployment.

🟢 GREEN measured, within budget · 🔴 RED measured, out of budget — a finding to clear before merge · ⚪ n/a not measurable on this machine, never guessed.

No RED rows.

Measured on Linux/x86_64 · measured 647909895e69 · PR head 647909895e69 · checkout 647909895e69 · run · updated 2026-11-05 06:02:08 UTC

@EtanHey
EtanHey force-pushed the claude/orc-final-credit-batch-z2uwd0 branch from a03bca7 to 1dd44ef Compare November 5, 2026 07:28

This branch has not been deployed

No deployments
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.

2 participants