Skip to content

feat(gate2): real Claude analyzer completes hn-monitor analyze-story - #130

Merged
kjgbot merged 1 commit into
mainfrom
flow/g2-real-analyzer-0902
Sep 2, 2026
Merged

feat(gate2): real Claude analyzer completes hn-monitor analyze-story#130
kjgbot merged 1 commit into
mainfrom
flow/g2-real-analyzer-0902

Conversation

@kjgbot

@kjgbot kjgbot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Evidence below was captured at 208def8; this branch head is 82be45f.
The only difference between them is a corrected comment in
live-kernel.test.ts plus commit-message fixes — no behaviour change, and the
analyzer blob is byte-identical (0db65085…) across both. The full suite was
re-run green at 82be45f: Test Files 17 passed (17) / Tests 237 passed (237) / FULL_EXIT=0.

Earlier commit messages on this branch made claims that did not hold: a stale
analyzer hash, "evidence in the PR body" when it was in a comment, and two
false ops/NEXT.md citations. The history lens caught each one. The history
was rewritten to remove them rather than annotated around them.

What this closes

Follow-up B in ops/reviews/20260901-1050-gate2-live-run.md. The merged live run (PR #121, 5835cba) ended all 9 analyzer attempts in worker_error → step_failed: analyze-story declared a schema but no CLI.

What ships

  1. testdata/preflight/analyze-story-claude-cli — reads the story from RELAYFLOW_WAKE_CONTEXT (PR feat(sdk): expose wake_context to agent CLIs via RELAYFLOW_WAKE_CONTEXT #125, 7b115bd), asks Claude to judge it, emits one JSON object carrying only the three schema-declared fields so no unvalidated model chatter reaches the journal (PR feat(sdk): AgentWorker promotes CLI JSON output for json_schema verification #124, 3855099). auth status does a live round-trip, not an existence check: presence says nothing about the model resolving or the session being authenticated, and a false "ready" would let a broken box emit a skip that reads like acceptance.

  2. The canonical spec declares it — YAML and compiled JSON. Declaring it only in a test copy would leave flows hn-monitor start shipping a spec with no CLI: a green test over a dead workload.

  3. resolveSpecCliPathsflows check resolves a relative cli against the spec's directory (check.ts probeCli); AgentWorker ends at spawn(cli, …), which resolves against the worker process's cwd. They coincide only when the runner starts from the spec's directory, so a spec that passed flows check could still die with ENOENT once launched. Returns a copy; tests both separators, since a Windows preflight\analyzer would otherwise be misread as a bare PATH command.

  4. model declared on agent steps, end to end — carried exactly where cli is: both SDK dialects, validation (empty string rejected), all four compiler sites incl. the kernel→authoring inverse, StepKind::Agent + its kernel field allow-list, and the worker, which surfaces it as RELAYFLOW_MODEL and leaves it unset when the step declares none. Preflight probes with the declared model in scope and keys its cache on it. No flow- or project-level default: inheriting a model from two levels up is the ambient-state problem the field removes.

    Needed because a CLI inheriting whatever the host pins gives runs whose model cannot be recovered from the journal, and hard failure on a host pinning an unresolvable alias. This box pins fable, which has broken four things here — including the review swarm's own maintainability lens, whose entire review body on this PR was that error instead of a verdict.

Scope: this edits kernel/, forbidden by BOTH briefs: the gate-2 brief lists Editing kernel/ under non-goals, and the ops/NEXT.md committed here lists Changes to the kernel under Explicitly OUT of scope (line 78). Khaliq directed it anyway, so it ships against both deliberately rather than by oversight. That instruction came from Khaliq, who owns these gates. Stated plainly rather than left for a reader to discover. The kernel change is inert — it carries and journals the field, never interprets it. No retry, scheduling, dedupe or lease logic.

Tree identity

$ git rev-parse HEAD
82be45ff91c1e77db8422b72324fba7ecf7fdda7
$ shasum -a 256 testdata/preflight/analyze-story-claude-cli
0db65085da275c64533a498a8755fea96d1609e7897d6f971817b4b99ed34c56
$ git status --porcelain
(empty — tree identical to the commit)

Fail-first evidence — four mutations, each restored

1. Analyzer absent. No RELAYFLOWS_* variable set, because strict is the default:

$ mv testdata/preflight/analyze-story-claude-cli /tmp/held-sq
$ npx vitest run tests/live-kernel.test.ts -t 'reaches done through the real Claude analyzer CLI'
× ... reaches done through the real Claude analyzer CLI 13ms
  → LIVE_ANALYZER_UNAVAILABLE: analyzer CLI does not exist: .../analyze-story-claude-cli
    — failing because gate-2 acceptance requires the real analyzer to execute.
      Set RELAYFLOWS_ALLOW_ANALYZER_SKIP=1 only if this run is not gate evidence.
FAILFIRST_EXIT=1

2. Worker drops the model pass-through (runCli(..., spec.model)runCli(...)):

× AgentWorker passes a declared model to the CLI as RELAYFLOW_MODEL 249ms
  → expected 'model:UNSET' to be 'model:declared-model-xyz'
MUTATION_A_EXIT=1

3. Remove the explicit delete env[MODEL_ENV] — the parent-inheritance leak. The test pollutes process.env first precisely to catch it:

× AgentWorker leaves RELAYFLOW_MODEL UNSET when the step declares no model 271ms
  → expected 'model:leaked-parent-model' to be 'model:UNSET'
MUTATION_B_EXIT=1

Without that one line a step declaring no model silently runs pinned to whatever the launching shell exported.

4. Remove "model" from the kernel's STEP_AGENT_FIELDS — SDK/kernel parity:

test the_kernel_parses_the_rung_c_agent_spec_and_stamps_the_same_hash ... FAILED
test result: FAILED. 4 passed; 1 failed
MUTATION_C_EXIT=101

hello-agent now declares model: test-model-v1, so the existing bit-for-bit parity pair covers the field. Canonical JSON and sha256 were regenerated by the compiler, not hand-edited.

Green after restore

$ mv /tmp/held-sq testdata/preflight/analyze-story-claude-cli
restored sha=0db65085da275c64533a498a8755fea96d1609e7897d6f971817b4b99ed34c56
$ git status --porcelain
(empty — identical to the commit)

$ npx vitest run
LIVE_ANALYZER analysis: {"reasoning":"This story is directly about an AI agent that autonomously opens and reviews pull requests, demonstrating practical application of agent automation in software development workflows. The self-reviewing capability exemplifies how agents can reduce manual overhead in development processes.","relevance_score":9,"story_title":"Show HN: an agent that opens and reviews its own pull requests [wake-nonce-7f3a91c4]"}
 Test Files  17 passed (17)
      Tests  237 passed (237)
FULL_EXIT=0

$ cargo test        # kernel, CARGO_TARGET_DIR pinned to this worktree
KERNEL_TEST_EXIT=0  (12 test binaries, 0 failed)

The title returns carrying wake-nonce-7f3a91c4. Nothing else on the machine or in training data contains that string, so only an analyzer that received this event's wake context can echo it.

Preflight really probes the declared model

$ node sdk/dist/cli.js check testdata/hn-monitor.flow.yaml   # model: definitely-not-a-real-model-xyz
REFUSED [cli_unauthenticated] Step "analyze-story" declares CLI "preflight/analyze-story-claude-cli",
  but "... auth status" exited non-zero
BOGUS_MODEL_CHECK_EXIT=2

$ node sdk/dist/cli.js check testdata/hn-monitor.flow.yaml   # restored
RESOLVED step "analyze-story" cli "preflight/analyze-story-claude-cli" from step
CHECK PASSED testdata/hn-monitor.flow.yaml
RESTORED_CHECK_EXIT=0

Live launched workload

flows hn-monitor start on the canonical spec against real Hacker News — not the test harness — with ANTHROPIC_MODEL and RELAYFLOW_MODEL both unset, so the declared spec value is the only thing that can work on a box pinning fable:

$ relayflowd --data-dir /tmp/gate2-model/.relayflowd serve &
$ env -u ANTHROPIC_MODEL -u RELAYFLOW_MODEL node sdk/dist/cli.js hn-monitor start \
    --data-dir /tmp/gate2-model/.relayflowd --poll-interval-ms 30000 \
    testdata/hn-monitor.spec.canonical.json &

$ date -u
2026-09-02T09:30:09Z
$ env | grep -cE '^(ANTHROPIC_MODEL|RELAYFLOW_MODEL)='
0
$ sqlite3 relayflowd.sqlite3 "SELECT status, COUNT(*) FROM runs GROUP BY status;"
completed|5
$ # per-run: completionReason|verdict|relevance_score|story_title
success|pass|1|Commodore 64 released September 1, 1982
success|pass|8|Claude Fable 5.1 and Claude Mythos 5.1
success|pass|5|The Emergent Symbolic Structure of Artificial Neural
success|pass|5|How accurate have Ed Zitron's AI skeptic predictions
success|pass|2|Fine, I'll build my own text editor
$ # journaled model on the dispatched step, from run.spawned
claude-haiku-4-5-20251001

Five real stories, five completed, zero failed — the inverse of the recorded 9-runs-9-failed. The model is recoverable from run.spawned, which is the point of declaring it. Scores discriminate: 1 for a Commodore 64 anniversary post, 8 for a model release.

Known tensions, not fixed here

  • resolveSpecCliPaths puts a host-specific absolute path into journaled state, in tension with RFC decision 14's digest/provenance model. Structure lens raised it as non-blocking; correct fix is resolving at the worker/ingestion boundary so every runner inherits it, rather than per-runner. Out of gate-2 scope.
  • CLI-path resolution now exists in three places (resolveSpecCliPaths, probeCli, the bare spawn), so a future runner submitting a relative cli re-inherits the ENOENT bug.

Toolchain defects found, reported not hidden

  1. npm hangs herenpm -v exits 124; ~/.npmrc symlinks into a Dropbox path whose read hangs. All npm use needs NPM_CONFIG_USERCONFIG=/dev/null.
  2. npm test is not runnable on this hosttest:prep dies with mise ERROR cargo is not a valid shim before reaching vitest.
  3. ops/cargo.sh is broken here — it forces CARGO_HOME/RUSTUP_HOME to ~/.relayflows-toolchain, which is empty, so the mise shim fails and the rustup proxy reports could not choose a version of cargo to run. I built with CARGO_TARGET_DIR set to the same per-worktree path the script computes rather than edit a gate script.
  4. locateRelayflowd can test the wrong binary — it picks the most recently built relayflowd across all worktree keys. This worktree is 1914866954; tests had been resolving 3076306992, built from a different worktree. Harmless until a PR touches kernel/ — as this one does. Every kernel-dependent run above pins RELAYFLOWD_BIN to this worktree's own build.
  5. testdata/hn-monitor.spec.canonical.json has never passed flows check — snake_case trigger keys vs the checker's camelCase. Verified identical refusal at 7728565. Pre-existing, untouched.
  6. Both review harnesses call claude -p with no --model (ops/preswarm-check/lens-runner.sh:205, review-swarm-loop.sh:145), so on a host pinning an unresolvable alias the lens returns the model error as its review body — an environment failure shaped like a verdict. That produced the earlier M:unclear aggregate here.
  7. review-swarm-loop.sh cannot be run as its own usage line documents — it says sh …, but line 278 uses bash process substitution.

I edited none of these: AGENTS.md says never edit a gate that judges your own work.

@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

Next included review available in 1 minute.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: a1eddbd5-3e2f-47f7-b735-99f6d29dfed1

📥 Commits

Reviewing files that changed from the base of the PR and between 35eb8fd and 82be45f.

📒 Files selected for processing (16)
  • kernel/relayflowd-core/src/spec.rs
  • sdk/src/cli/check.ts
  • sdk/src/cli/hn-monitor.ts
  • sdk/src/compile.ts
  • sdk/src/preflight.ts
  • sdk/src/spec.ts
  • sdk/src/validate.ts
  • sdk/src/worker.ts
  • sdk/tests/live-kernel.test.ts
  • testdata/hello-agent.flow.yaml
  • testdata/hello-agent.spec.canonical.json
  • testdata/hello-agent.spec.sha256
  • testdata/hn-monitor.flow.yaml
  • testdata/hn-monitor.spec.canonical.json
  • testdata/preflight/analyze-story-claude-cli
  • testdata/preflight/echo-model-cli
ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 79bad8ec-9dee-45cc-957c-8dce3442c64c

📥 Commits

Reviewing files that changed from the base of the PR and between 43c27b0 and 35eb8fd.

📒 Files selected for processing (5)
  • sdk/src/cli/hn-monitor.ts
  • sdk/tests/live-kernel.test.ts
  • testdata/hn-monitor.flow.yaml
  • testdata/hn-monitor.spec.canonical.json
  • testdata/preflight/analyze-story-claude-cli

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The change adds a Claude-backed analyze-story CLI, relative CLI path resolution, and a live kernel acceptance test. The CLI validates authentication and wake context, requests structured analysis, and emits required fields. The test verifies schema validation and run completion.

Changes

Claude analyzer flow

Layer / File(s) Summary
Analyzer CLI path resolution
sdk/src/cli/hn-monitor.ts, testdata/hn-monitor.flow.yaml, testdata/hn-monitor.spec.canonical.json
The monitor resolves relative step CLI paths against the specification directory. The workflow and canonical specification use the relative analyze-story CLI path.
Claude analyzer execution
testdata/preflight/analyze-story-claude-cli
The CLI checks authentication, validates wake context, retrieves missing Hacker News data, requests structured story analysis, parses the response, validates required fields, and emits story_title, relevance_score, and reasoning.
Live kernel acceptance and readiness handling
sdk/tests/live-kernel.test.ts
The test probes analyzer readiness, supports configurable step timeouts, submits a story event, and verifies analyzer output, schema validation, and run completion.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 35eb8

The PR adds the real analyzer execution path and supporting path resolution, with no actionable merge-blocking risk remaining after normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant LiveTest
  participant Kernel
  participant ClaudeCLI
  participant Claude
  LiveTest->>ClaudeCLI: Run auth status probe
  ClaudeCLI->>Claude: Perform authenticated round trip
  Claude-->>ClaudeCLI: Return readiness result
  LiveTest->>Kernel: Submit story event
  Kernel->>ClaudeCLI: Analyze wake context
  ClaudeCLI->>Claude: Request structured story analysis
  Claude-->>ClaudeCLI: Return JSON analysis
  ClaudeCLI-->>Kernel: Emit validated story fields
  Kernel-->>LiveTest: Return schema pass and completed run
Loading

Poem

A rabbit checks the Claude gate
Then sends a story through the state
JSON fields hop neat and bright
The kernel marks the run all right
Long waits keep the carrots near


Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing.

Comment @coderabbitai help to get the list of available commands.

@kjgbot

kjgbot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

maintainability lens — UNCLEAR

There's an issue with the selected model (fable). It may not exist or you may not have access to it. Run --model to pick a different model.

@kjgbot

kjgbot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

history lens — FAIL

Blocker

  • The commit message falsely claims this “Closes RFC-0001 gate 2 follow-up B.” The diff only adds an analyzer executable and injects its path into an in-memory test copy of the canonical spec (sdk/tests/live-kernel.test.ts:700-749). It does not modify testdata/hn-monitor.spec.canonical.json or the production flows hn-monitor start path. Consequently, the real workload still supplies no step.cli and can repeat the previously recorded worker_error → step_failed behavior. This is a commit-message untruth about scope/completion, not merely an acceptable deferral. Either wire the analyzer into the launched workload or describe this honestly as test scaffolding and name the remaining production-wiring follow-up.

Concerns — non-blocking

  • The default test can skip when Claude is unavailable (sdk/tests/live-kernel.test.ts:700-713). This does not repeat the prior “skip presented as acceptance” mistake because the branch is explicitly labeled diagnostic and the PR’s evidence used RELAYFLOWS_REQUIRE_LIVE_ANALYZER=1.
  • The test submits a synthetic, fully populated story rather than exercising the real HN poller/fallback path (sdk/tests/live-kernel.test.ts:738-749). That is not production gate-2 evidence, but deployment is explicitly deferred.

Notes

  • No new settled-RFC architectural contradiction: Claude runs SDK-side, not in the kernel.
  • The implementation genuinely invokes Claude and checks the kernel-recorded schema verdict (testdata/preflight/analyze-story-claude-cli:22-48, sdk/tests/live-kernel.test.ts:755-798), so it does not reintroduce the earlier hand-authored-stub acceptance pattern.

REVIEW_FAILED

@kjgbot

kjgbot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

structure lens — PASS

→ Read docs/RFC-0001-everything-is-a-relayflow.md
→ Read AGENTS.md

Structure review — PR #130

Boundaries. Nothing here reaches into the Rust kernel or the journal protocol; the change is confined to sdk/tests/live-kernel.test.ts plus one new testdata/ fixture. No product logic is introduced inside kernel/, and no provider SDK or tenant awareness appears. That is the right shape for gate-2 acceptance evidence.

Primitives vs. helpers. Good discipline here. probeAnalyzer is a helper, not a new step verb or kernel primitive; waitForStep gains a timeoutMs parameter rather than a sibling function. The kernel vocabulary stays closed (decision #13). No new deterministic/llm/agent semantics are smuggled in.

Fail-closed / completionReason discipline. The test asserts completionReason === 'success' on run.completed and asserts the kernel's own json_schema verdict: 'pass' rather than re-deriving it — the journal is treated as the record, consistent with decision #7 and Covenant 2. The CLI fails closed on missing RELAYFLOW_WAKE_CONTEXT, unreadable JSON, and non-object replies.

Concerns.

  1. The fixture is a second implementation of the analyzer. testdata/preflight/analyze-story-claude-cli:1-134 embeds non-trivial product logic: the relevance-scoring prompt rubric (lines ~107-124), a firebase fetch fallback (lines ~76-88), markdown-fence unwrapping (line ~110), and field whitelisting (line ~128). This is not a minimal fixture; it is a hand-rolled reimplementation of the real analyze-story step, used to prove the real path. Any drift between this fixture and the actual analyzer silently invalidates the acceptance the test claims to provide. Worth documenting where the canonical analyzer lives so the two can't diverge.

  2. probeAnalyzer reimplements the preflight auth contract. The comment at live-kernel.test.ts:1105-1110 says it reuses the flows check contract, but it does so by re-writing auth status handling locally rather than importing sdk/src/preflight.ts. If the preflight contract changes, this probe drifts and a skip stops matching what flows check actually refuses.

  3. Hardcoded model pin. analyze-story-claude-cli:20 hardcodes claude-haiku-4-5-20251001 as the fallback. A dated model alias in a fixture is a future silent-break; the RELAYFLOWS_ANALYZER_MODEL escape hatch mitigates but doesn't remove the drift risk.

Notes. live-kernel.test.ts grows ~120 lines to well over 1100; it is still a test file, but the preflight-probe helpers may warrant extraction if more real-CLI tests are added. The two-role CLI (auth status vs analyze) is acceptable for a fixture but is two responsibilities in one file.

No blockers.

REVIEW_PASSED

@kjgbot

kjgbot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

🎯 review-swarm: FAILED (M:unclear H:fail S:pass)

Lens transcripts posted as sibling comments above.

@kjgbot
kjgbot force-pushed the flow/g2-real-analyzer-0902 branch from 43c27b0 to 78a8b19 Compare September 2, 2026 08:04
@kjgbot

kjgbot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Review round: 3-lens pre-swarm relayflow (workflows/preswarm-check.yaml)

CodeRabbit returned a walkthrough with zero actionable comments, which is a vacuous green — so I ran the repo's own review gate instead. It found real problems. Round 1 raised four blockers; all four are fixed at 78a8b19:

  1. Skip-by-default inverted the acceptance principle. The test skipped unless an env var was set, so a reader running the suite normally got a green that proved nothing about gate 2. Now fails by default; skipping is opt-in via RELAYFLOWS_ALLOW_ANALYZER_SKIP=1. This directly implements ops/NEXT.md item 3.
  2. reasoning.length > 20 admitted a stub — a 21-char placeholder passed. Raised to > 100; observed model replies run 200+.
  3. The title assertion did not prove wake-context delivery. The submitted title now carries a nonce (wake-nonce-7f3a91c4) that exists nowhere else, so echoing it back is only possible for an analyzer that received this event's context. Asserted explicitly.
  4. Firebase fetch fallback undocumented. Documented as a deliberate non-goal: it needs live network, and a flaky network would otherwise be able to fail the gate-2 signal.

Round 2 additionally caught a genuine repo-covenant violation: the fetch fallback swallowed errors silently, which contradicts AGENTS.md "no silent fallbacks". It now reports the degradation on stderr. Also added the timeout-nesting rationale (150s step wait inside 180s test timeout, so a slow model surfaces as a named assertion rather than an opaque vitest kill) and documented the Node >= 22 floor.

Verification after the fixes

Fail-first now needs no env var, since strict is the default:

$ mv testdata/preflight/analyze-story-claude-cli /tmp/held3
$ npx vitest run tests/live-kernel.test.ts -t 'reaches done through the real Claude analyzer CLI'
× ... reaches done through the real Claude analyzer CLI 12ms
  → LIVE_ANALYZER_UNAVAILABLE: analyzer CLI does not exist: ... — failing because gate-2
    acceptance requires the real analyzer to execute. Set RELAYFLOWS_ALLOW_ANALYZER_SKIP=1
    only if this run is not gate evidence.
FAILFIRST3_EXIT=1

Restored, full suite, no env vars set:

$ npx vitest run
LIVE_ANALYZER analysis: {"reasoning":"This story directly describes an AI agent system that autonomously performs software development tasks including opening and reviewing pull requests, making it highly relevant to the AI agents and automation domain.","relevance_score":9,"story_title":"Show HN: an agent that opens and reviews its own pull requests [wake-nonce-7f3a91c4]"}
 Test Files  17 passed (17)
      Tests  235 passed (235)
FULL_EXIT=0

The nonce comes back intact — wake context reached the model.

Gate defect: the maintainability lens is non-deterministic

I am not claiming a stable pre-swarm green, and I stopped re-rolling deliberately rather than farming one.

Against the same commit and same diff, back-to-back:

RUN1 exit=1 verdict=PRESWARM_maintainability: REVIEW_FAILED
RUN2 exit=0 verdict=PRESWARM_maintainability: REVIEW_PASSED

Each failing roll emits a different list of stylistic "blockers" (extract a STEP_ID constant, add runtime validation before as casts, name the 30_000 probe timeout). I judged these against repo standards rather than complying reflexively: sibling tests in this same file use identical as casts, and AGENTS.md rule 6 is "no speculative abstraction". Complying would make my test inconsistent with its neighbours.

Two further observations on the gate itself, neither of which I touched, since AGENTS.md says never edit a gate that judges your own work:

  • One roll ended NO_VERDICT — no anchored REVIEW_PASSED or REVIEW_FAILED line (CLI exit=0) because the model emitted **REVIEW_FAILED** in bold. The gate cannot distinguish failed from malformed.
  • ops/preswarm-check/lens-runner.sh:205 invokes claude -p with no --model. On this host that hits the unresolvable fable alias, so the lens fails for environmental reasons unrelated to the diff. Kernel-executed steps inherit the daemon's environment, so the daemon must be started with ANTHROPIC_MODEL set or every lens fails. That cost me a full debug cycle and will cost the next person one.

One test failure I caused and then cleared

An earlier full-suite run showed runs rung (a), parks rung (b) failing on a 5s timeout. Cause was mine, not the code: I had left a stray relayflowd from the pre-swarm run competing for resources. Killed it; the test passes in 1.7s against a 5s budget, and the clean full suite above is green. Recording it because it was real and briefly on the branch.

kjgbot pushed a commit that referenced this pull request Sep 2, 2026
A CLI that inherits whatever model the host pins produces two failures:
runs whose model cannot be recovered from the journal, and hard failure
on a host pinning an alias the CLI cannot resolve. Both are live on this
machine — ~/.claude/settings.json pins "fable", and `claude -p` without
an explicit model answers "There's an issue with the selected model
(fable)". That has bitten four separate places in this repo, including
the review swarm's own maintainability lens, whose entire review body on
PR #130 was that error message rather than a verdict.

So `model` becomes a declared, journaled property of an agent step,
carried the same way `cli` already is:

- sdk/src/spec.ts: model on AgentStepSpec and KernelAgentStep.
- sdk/src/validate.ts: allow-listed and type-checked. The empty string is
  rejected — it would reach the CLI as RELAYFLOW_MODEL='', which reads as
  "declared, and declared as nothing"; absent and empty must not look
  alike.
- sdk/src/compile.ts: carried through all four sites cli uses, including
  the kernel->authoring inverse, so a round trip is lossless.
- kernel/relayflowd-core/src/spec.rs: model on StepKind::Agent and its
  field allow-list. The kernel never calls a model and never interprets
  this; it carries and journals it so the choice is part of the run's
  record, then hands it to the worker.
- sdk/src/worker.ts: surfaced to the CLI as RELAYFLOW_MODEL, and
  explicitly UNSET when the step declares none — same undefined-vs-empty
  discipline as RELAYFLOW_WAKE_CONTEXT. Without the explicit unset a
  leaked parent variable would silently pin a step that declared nothing.
- sdk/src/preflight.ts + cli/check.ts: the probe now runs with the
  declared model in scope, so readiness answers "can this CLI use THIS
  model" instead of the weaker "is this CLI authenticated at all". Model
  is part of the probe cache key, or a model the CLI cannot resolve would
  inherit an earlier model's pass. There is deliberately no flow- or
  project-level default: inheriting a model from two levels up is the
  ambient-state problem this field removes.

NOTE ON SCOPE: this edits kernel/, which ops/NEXT.md lists under explicit
non-goals and which my earlier commits on this branch said they avoided.
That instruction came from Khaliq, who owns these gates. Recording it
here so the history does not read as a quiet violation.

Also fixes a Windows bug the review swarm caught in resolveSpecCliPaths:
it detected bare command names with `cli.includes('/')`, so a relative
`preflight\analyzer` would be misread as a PATH command and left
unresolved. Both separators are now checked.

Tests, all with fail-first evidence captured in the PR body:
- SDK/kernel parity: hello-agent now declares a model, so the existing
  bit-for-bit parity pair covers it. Canonical JSON and sha256 regenerated
  by the compiler. Reverting the kernel allow-list fails the Rust parity
  test, proving both halves are pinned to one dialect.
- Worker set/unset pair against a live kernel, using a probe CLI that
  reports UNSET and EMPTY as distinct answers. The unset case pollutes
  process.env first, so it fails if the explicit unset is removed.

Live acceptance, launched with ANTHROPIC_MODEL and RELAYFLOW_MODEL both
unset so the declared value is the only thing that can work on this host:
5 real Hacker News stories, 5 completed, 0 failed, every step verdict
pass / completionReason success, and run.spawned journals
model=claude-haiku-4-5-20251001. `flows check` refuses the flow when the
declared model is bogus, which is the probe doing real work.

Session-Id: d4302017-1150-4ce8-8b77-01a5248b1414
@kjgbot

kjgbot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Scope addition at d7cf581: model declared on agent steps, end to end

New requirement from Khaliq, now part of this PR. A CLI that inherits whatever model the host pins gives you runs whose model cannot be recovered from the journal, and hard failure on a host pinning an unresolvable alias. Both are live here — ~/.claude/settings.json pins fable, and this has now bitten four places in this repo, including the review swarm's own maintainability lens, whose entire review body on this PR was the model error rather than a verdict.

Scope note, stated plainly: this edits kernel/, which ops/NEXT.md lists under explicit non-goals and which my earlier commits said they avoided. The instruction came from Khaliq, who owns those gates. Recording it so the history doesn't read as a quiet violation.

What carries it

model is plumbed exactly where cli already is: spec.ts (both dialects), validate.ts (allow-list + type check, empty string rejected), compile.ts (all four sites incl. the kernel→authoring inverse, so round trips are lossless), kernel/relayflowd-core/src/spec.rs (StepKind::Agent + field allow-list), worker.ts (RELAYFLOW_MODEL, explicitly unset when absent), and preflight.ts/check.ts (probe runs with the declared model in scope; model is part of the probe cache key).

The kernel never calls a model and never interprets this — it carries and journals it. There is deliberately no flow- or project-level default: inheriting a model from two levels up is the ambient-state problem the field removes.

Fail-first evidence — three mutations, each reverted after

A. Worker drops the model pass-through (runCli(..., spec.model)runCli(...)):

× AgentWorker passes a declared model to the CLI as RELAYFLOW_MODEL 249ms
  → expected 'model:UNSET' to be 'model:declared-model-xyz'
MUTATION_A_EXIT=1

B. Remove the explicit delete env[MODEL_ENV] — this is the parent-inheritance leak, and the test pollutes process.env first precisely to catch it:

× AgentWorker leaves RELAYFLOW_MODEL UNSET when the step declares no model 271ms
  → expected 'model:leaked-parent-model' to be 'model:UNSET'
MUTATION_B_EXIT=1

Without that one line, a step declaring no model silently runs pinned to whatever the launching shell exported.

C. Remove "model" from the kernel's STEP_AGENT_FIELDS — SDK/kernel parity:

test the_kernel_parses_the_rung_c_agent_spec_and_stamps_the_same_hash ... FAILED
test result: FAILED. 4 passed; 1 failed
MUTATION_C_EXIT=101

hello-agent now declares model: test-model-v1, so the existing bit-for-bit parity pair covers the new field; canonical JSON and sha256 were regenerated by the compiler, not hand-edited.

Green after restore

$ cargo test            # kernel
KERNEL_TEST_EXIT=0      (12 test binaries, 0 failed)
$ npx vitest run        # SDK
 Test Files  17 passed (17)
      Tests  237 passed (237)
FULL_EXIT=0

237, up from 235 — the two new worker tests.

Preflight actually probes the declared model

Bogus model declared:

$ node sdk/dist/cli.js check testdata/hn-monitor.flow.yaml   # model: definitely-not-a-real-model-xyz
REFUSED [cli_unauthenticated] Step "analyze-story" declares CLI "preflight/analyze-story-claude-cli",
  but "... auth status" exited non-zero
BOGUS_MODEL_CHECK_EXIT=2

Restored:

$ node sdk/dist/cli.js check testdata/hn-monitor.flow.yaml
RESOLVED step "analyze-story" cli "preflight/analyze-story-claude-cli" from step
CHECK PASSED testdata/hn-monitor.flow.yaml
RESTORED_CHECK_EXIT=0

Live acceptance with the host's own model pin left hostile

Launched with ANTHROPIC_MODEL and RELAYFLOW_MODEL both unset, so the declared spec value is the only thing that can make the analyzer work on a box pinning fable:

$ date -u
2026-09-02T09:30:09Z
$ env | grep -cE '^(ANTHROPIC_MODEL|RELAYFLOW_MODEL)='
0
$ sqlite3 relayflowd.sqlite3 "SELECT status, COUNT(*) FROM runs GROUP BY status;"
completed|5
$ # per-run: completionReason|verdict|score|title
success|pass|1|Commodore 64 released September 1, 1982
success|pass|8|Claude Fable 5.1 and Claude Mythos 5.1
success|pass|5|The Emergent Symbolic Structure of Artificial Neural
success|pass|5|How accurate have Ed Zitron's AI skeptic predictions
success|pass|2|Fine, I'll build my own text editor
$ # journaled model on the dispatched step
claude-haiku-4-5-20251001

Five real stories, five completed, zero failed, and the model is recoverable from run.spawned — which is the entire point of declaring it. Scores discriminate: 1 for a Commodore 64 anniversary post, 8 for a model release.

Also fixed here

The maintainability lens caught a real Windows bug in resolveSpecCliPaths: it detected bare command names with cli.includes('/'), so a relative preflight\analyzer would be misread as a PATH command and left unresolved. Both separators are now checked.

One more toolchain defect

ops/cargo.sh no longer works on this host: it forces CARGO_HOME/RUSTUP_HOME to ~/.relayflows-toolchain, which is empty (no cargo/bin, no rustup/toolchains), so the mise cargo shim fails with cargo is not a valid shim and the rustup proxy fails with could not choose a version of cargo to run. I built with CARGO_TARGET_DIR set to the same per-worktree path the script computes, rather than editing a gate script.

While doing that I found something worth flagging: locateRelayflowd in live-kernel.test.ts picks the most recently built binary across all worktree keys. This worktree's key is 1914866954, but tests had been resolving 3076306992 — a relayflowd built from a different worktree. That is fine when no one touches kernel/, and actively misleading now that this PR does. Every kernel-dependent run reported above pins RELAYFLOWD_BIN to this worktree's own build.

@kjgbot
kjgbot force-pushed the flow/g2-real-analyzer-0902 branch 2 times, most recently from 208def8 to 2e51800 Compare September 2, 2026 09:42
Closes RFC-0001 gate 2 follow-up B from
ops/reviews/20260901-1050-gate2-live-run.md. The merged live run
(PR #121, 5835cba) ended all 9 analyzer attempts in
worker_error -> step_failed: analyze-story declared a schema but no CLI.
The worker and trigger planes were already merged; the analyzer program
was the gap.

Squashed from four working commits. Two of those messages made evidence
claims that did not hold — one quoted an analyzer sha256 that a later
edit in the same branch invalidated, and one said fail-first evidence
was in the PR body when it was in a PR comment. The review swarm's
history lens caught both. They are removed rather than annotated,
because an acknowledgement elsewhere does not repair a false statement
in an immutable commit message. This message therefore states what was
verified and leaves the captured commands and outputs to the PR body,
which is regenerated against this exact tree.

What ships:

- testdata/preflight/analyze-story-claude-cli. Reads the story from
  RELAYFLOW_WAKE_CONTEXT (PR #125, 7b115bd), asks Claude to judge it,
  and emits one JSON object carrying only the three schema-declared
  fields, so no unvalidated model chatter reaches the journal (PR #124,
  3855099). `auth status` performs a live round-trip rather than
  checking the binary exists: presence says nothing about the model
  resolving or the session being authenticated, and a false "ready"
  would let a broken box emit a skip that reads like acceptance.

- The canonical hn-monitor spec DECLARES that CLI, in both the YAML and
  the compiled JSON. Declaring it in a test copy only would have left
  `flows hn-monitor start` shipping a spec with no CLI — a green test
  over a dead workload.

- resolveSpecCliPaths, because the two halves of the system disagreed
  about what a relative cli path means. `flows check` resolves it
  against the SPEC's directory (sdk/src/cli/check.ts probeCli), while
  AgentWorker ends at spawn(cli, ...), which resolves against the WORKER
  PROCESS's cwd. They coincide only when the runner starts from the
  spec's directory, so a spec that passed `flows check` could still die
  with ENOENT once launched. It returns a copy, and it tests for both
  path separators — a Windows `preflight\analyzer` would otherwise be
  misread as a bare PATH command.

- `model` as a declared, journaled property of an agent step, carried
  exactly where `cli` already is: SDK authoring and kernel dialects,
  validation, all four compiler sites including the kernel->authoring
  inverse, StepKind::Agent and its field allow-list in the kernel, and
  the worker, which surfaces it to the CLI as RELAYFLOW_MODEL and leaves
  it UNSET when the step declares none. Preflight probes with the
  declared model in scope, so readiness answers "can this CLI use THIS
  model" rather than "is this CLI authenticated at all", and the model
  is part of the probe cache key. There is deliberately no flow- or
  project-level default; inheriting a model from two levels up is the
  ambient-state problem the field removes.

  Why it is needed: a CLI inheriting whatever the host pins gives runs
  whose model cannot be recovered from the journal, and hard failure on
  a host pinning an unresolvable alias. This machine pins "fable", and
  that has broken four things here, including the review swarm's own
  maintainability lens, whose entire review body on this PR was that
  error message instead of a verdict.

An unavailable analyzer FAILS the acceptance test by default; skipping
is opt-in via RELAYFLOWS_ALLOW_ANALYZER_SKIP=1. The rule it implements:
a skip is diagnostics and never acceptance evidence, so strict has to be
the default rather than a convention a reader has to know about.

That rule comes from the gate-2 brief this task was given, which lives
outside this branch. The ops/NEXT.md committed here is a DIFFERENT,
older brief about building the worker, so the two do not share item
numbers. An earlier version of this message, and a comment in
live-kernel.test.ts, cited "ops/NEXT.md item 3" for the rule above,
which is false against the committed copy — item 3 there is the
worker-attach rule. Both citations are removed rather than renumbered.

SCOPE: this edits kernel/, and BOTH briefs forbid that. The gate-2 brief
lists "Editing kernel/" under explicit non-goals, and the ops/NEXT.md
committed here lists "Changes to the kernel" under "Explicitly OUT of
scope" (line 78). Khaliq, who owns these gates, directed the kernel work
anyway, so it ships against both briefs deliberately rather than by
oversight. An earlier version of this message claimed the committed
ops/NEXT.md had no non-goals section; that was wrong — I had grepped for
"non-goal" and missed the "OUT of scope" wording. The kernel change is
inert: it carries and journals the field and never interprets it.
Stated here so the history does not read as a quiet violation. The
kernel change is inert — it carries and journals the field and never
interprets it. No retry, scheduling, dedupe or lease logic was added.

Session-Id: d4302017-1150-4ce8-8b77-01a5248b1414

Session-Id: d4302017-1150-4ce8-8b77-01a5248b1414

Session-Id: d4302017-1150-4ce8-8b77-01a5248b1414
@kjgbot
kjgbot force-pushed the flow/g2-real-analyzer-0902 branch from 2e51800 to 82be45f Compare September 2, 2026 09:48
@kjgbot

kjgbot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

maintainability lens — PASS

Exact head 82be45f. Run manually from workflows/review-swarm.yaml's lens prompts (verbatim), because review-swarm-loop.sh skips a PR that already carries a marker.

Reading the charter and RFC...

This PR adds a model field to agent steps so CLIs receive a declared, journaled model instead of inheriting ambient host state—solving a key portability problem noted in RFC-0001 (gate 8: "traceable work"). The implementation is thoughtful but has several maintainability boundaries worth naming.

Blockers: None

The plumbing works, tests are solid, and the change aligns with the RFC's identity + credentials covenant (gate 8).

Concerns

1. resolveSpecCliPaths coupling & re-entrancy
The resolveSpecCliPaths function (hn-monitor.ts:88–124) is exported but only called in runHnMonitor at one site. Its comment explains why it's needed (probeCli vs spawn resolve from different cwds) but not when it must be called. A future maintainer adding a new runner (e.g., a direct agent gateway) might not know they need to call it before dispatch. The comment is excellent, but the function's export-path-is-usage asymmetry creates an implicit contract: "call this before submitting a spec to the kernel." That should be structural—either unshrouded (a clarifying docstring: "Must be called by all external runners before dispatch") or enforced (the kernel itself validates paths; see gate 7's "execution placement is the engine's job").

2. Env-var deletion pattern isn't DRY
Lines in check.ts:184–190 and worker.ts:207–211 both follow the same shape:

delete env[MODEL_ENV];
if (model !== undefined) env[MODEL_ENV] = model;

With identical reasoning in both comments ("explicit unset prevents inherited pollution"). This is a silent contract that a future editor of one site won't see the other and might get it wrong. Extract to a helper: setOrUnsetEnv(env, MODEL_ENV, model).

3. Empty-string validation not covered by visible tests
The validator in validate.ts:318–325 rejects model: '' because "the CLI cannot tell it from a real value," and the reasoning is good. But the live-kernel tests check only model: undefined (unset) and model: 'declared-model-xyz' (set). There's no model: '' case. If someone later adds a test checking "empty model passes," they won't realize it breaks the contract. Add one: testAssertEqual(validate({ model: '' }).errors, ['non-empty string']) or equivalent.

4. Test pollution is risky
The test "leaves RELAYFLOW_MODEL UNSET" (line 731) sets process.env.RELAYFLOW_MODEL = 'leaked-parent-model' and cleans it up with delete process.env.RELAYFLOW_MODEL; at line 758. If the test throws before line 758, the variable stays set and poisons other tests in the suite. Use a try/finally or move the delete into a teardown hook so it always runs.

5. Cache-key inclusion is correct but not obviously so
The cacheKey in preflight.ts:157 includes resolution.model ?? null. The comment explains why (same CLI with different models is two questions), but the ?? null for serialization is subtle. Future readers might not realize: does stringify(undefined) differ from stringify(null)? It does—but someone might refactor to JSON.stringify([...]) and lose the distinction without realizing. Add a unit test: test('cache keys differ for same CLI, different models', ...).

6. Analyzer CLI requires Node ≥ 22 silently
The new analyze-story-claude-cli throws at line 25 if Node < 22. This is correct for the ESM syntax, but the gate-2 test relies on this probe succeeding. The error path works (the test checks readiness.ready and skips), but a maintainer debugging a skip without seeing that error message might be confused. Consider: log the Node version floor to stdout in the auth status path so readiness.detail names it.

Notes

  • Good: The test harness is comprehensive (set, unset, real analyzer round-trip) and the comments extensively document why each boundary exists (e.g., "model is step-only, never inherited"; "the author's model choice is journaled with the step").
  • Good: The validation of non-empty strings is strict and enforced at the schema boundary.
  • Good: The probeAnalyzer function in tests correctly reuses the preflight contract (auth status), so a broken analyzer fails consistently in both check and live tests.

REVIEW_PASSED

@kjgbot

kjgbot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

history lens — PASS

Exact head 82be45f. Run manually from workflows/review-swarm.yaml's lens prompts (verbatim), because review-swarm-loop.sh skips a PR that already carries a marker.

Blockers

None. The diff neither repeats a DRIVE-LOG mistake nor contradicts a settled RFC decision, and the squashed commit message accurately describes the final tree.

Concerns — non-blocking

  • resolveSpecCliPaths journals a host-specific absolute executable path (sdk/src/cli/hn-monitor.ts:108-123,186-188), creating tension with RFC-0001 decision 14’s content-addressed bundles. The PR body explicitly acknowledges this and names resolution at the worker/ingestion boundary as follow-up work, so the brief requires treating it as a concern rather than a blocker.
  • The live acceptance test depends on an external Claude runtime (sdk/tests/live-kernel.test.ts:782-813). It fails closed by default and permits skipping only through an explicit diagnostics-only opt-in, so it does not repeat the earlier false-green acceptance pattern.

Notes

  • The earlier history-lens blocker—testing an in-memory CLI injection while shipping a dead canonical workload—is genuinely repaired. The shipped flow declares both CLI and model (testdata/hn-monitor.flow.yaml:11-21), and the production runner applies the same path transformation used by the test (sdk/src/cli/hn-monitor.ts:186-188; sdk/tests/live-kernel.test.ts:830-845).
  • The analyzer performs a real model round-trip and emits only schema fields (testdata/preflight/analyze-story-claude-cli:44-69,119-161). The worker passes the declared model while clearing inherited ambient state (sdk/src/worker.ts:89-94,180-206).
  • The Rust change remains an inert spec carrier (kernel/relayflowd-core/src/spec.rs:182-189,282-291), consistent with the kernel boundary.
  • Commit-message scope and evidence claims match the diff and PR record; the final rewrite also removes the previously false citations and stale hash claim.

REVIEW_PASSED

@kjgbot

kjgbot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

structure lens — PASS

Exact head 82be45f. Run manually from workflows/review-swarm.yaml's lens prompts (verbatim), because review-swarm-loop.sh skips a PR that already carries a marker.

build · deepseek-v4-pro

→ Read docs/RFC-0001-everything-is-a-relayflow.md
→ Read kernel/relayflowd-core/src/spec.rs [offset=170, limit=140]
✱ Grep "function resolveExecutable|function resolveCli|resolveSpecCliPaths|MODEL_ENV" in sdk/src · 10 matches
→ Read sdk/src/cli/check.ts [offset=1, limit=40]
→ Read sdk/src/cli/check.ts [offset=199, limit=30]

STRUCTURE review — PR #130 (model on agent steps)

Kernel boundary — clean. StepKind::Agent { model: Option<String> } (spec.rs:282-291) is carried-and-journaled only; the doc comment states the kernel never calls or interprets it. That is not product logic in the kernel — and it does not add a primitive. model is already closed-vocabulary on Llm (spec.rs:275-278) and STEP_LLM_FIELDS (spec.rs:181). The change makes agent symmetric with llm; no new step verb, no new machine. This matches RFC Appendix A rule 3 / gate 8's provenance discipline ("which agent, in which step, why") and decision 4's "LLM calls happen SDK-side."

Concern 1 — helper in the wrong home, and a cross-layer import. resolveSpecCliPaths (hn-monitor.ts:108) is a generic spec transform — any agent step with a relative CLI has this exact ENOENT problem, nothing hn-monitor-specific. Placing it in cli/hn-monitor.ts grows that command's file past its purpose, and live-kernel.test.ts imports it from ../src/cli/hn-monitor.js, coupling a kernel-boundary test suite to a specific CLI surface. It belongs in spec.ts (or a paths.ts) next to the other spec transforms.

Concern 2 — the path-resolution rule is now duplicated. resolveExecutable (check.ts:199-213) and resolveSpecCliPaths (hn-monitor.ts:108-134) independently encode the same contract: "a name with a separator resolves against the spec/dir; a bare name is a PATH lookup." Notably the new helper adds Windows \\ handling while the old one still only tests / (check.ts:200). The PR's own rationale is that preflight and worker had disagreed; the fix re-implements the rule a second time instead of sharing one helper both call, reintroducing the drift class it closes. This is the strongest structural finding.

Notes. MODEL_ENV owned in worker.ts and imported by check.ts — correct single-owner env contract. Fail-closed is respected: explicit delete env[MODEL_ENV] before conditional set in both worker.ts:205 and check.ts:186, and the "unset/empty/leaked" test pins it. The two 161- and 18-line testdata/preflight/*-cli fixtures are within testdata's purpose. No completionReason regression visible — new assertions read run.completed reason success.

No blockers; the kernel change is sound. Concerns 1 and 2 are worth landing alongside (ideally the shared helper) before this hardens.

REVIEW_PASSED

@kjgbot

kjgbot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

🎯 review-swarm: PASSED (M:pass H:pass S:pass) @ 82be45f

Exact-head run against 82be45ff91c1e77db8422b72324fba7ecf7fdda7. Lens transcripts posted as sibling comments above. Supersedes the 35eb8fd-era marker.

@kjgbot
kjgbot merged commit 51415d9 into main Sep 2, 2026
2 checks passed
@kjgbot kjgbot mentioned this pull request Sep 7, 2026
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