Skip to content

refactor: add a run-listener observer and adopt it in the cli#156

Merged
achuvyas-kv merged 4 commits into
masterfrom
refactor-run-listener
Jul 3, 2026
Merged

refactor: add a run-listener observer and adopt it in the cli#156
achuvyas-kv merged 4 commits into
masterfrom
refactor-run-listener

Conversation

@prasanth-nair-kv

@prasanth-nair-kv prasanth-nair-kv commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

What & why

Introduce RunListener — an Observer over the run lifecycle — so reporters,
progress UIs, and telemetry become listeners and a new output format needs
no engine edits. The engine keeps its single ProgressEvent stream;
dispatchProgress adapts it to typed hooks.

  • core/src/execute/runListener.ts (new) — RunListener (all hooks optional:
    onRunStart/onEvaluatorStart/onAttackStart/onAttackDone/onEvaluatorDone/
    onRunStopped/onRunError/onRunFinish) with payloads derived from
    ProgressEvent (Omit<Extract<…>>) so they can't drift; notifyListeners
    (error-isolating fan-out); dispatchProgress with a compile-time exhaustiveness guard.
  • runAll.tslisteners?: RunListener[] (additive); notify fans out to
    onProgress and listeners; onRunStart before the loop, onRunFinish after
    the report, onRunError in a catch.
  • CLI adopterConsoleProgressListener implements RunListener; run.ts swaps
    its inline onProgress switch for listeners: [new ConsoleProgressListener()],
    proving the pattern end-to-end (identical ▶/✓/✗/⚠ output).
  • lib/verdictIcon.ts — shared verdict→glyph helper.

Robustness (from the review)

  • Error isolation — a throwing listener is logged and skipped; it can never
    abort the run (critical for telemetry/reporter adopters that do I/O).
  • Symmetric lifecycleonRunStart always pairs with a terminal hook:
    onRunFinish (success/stop) or onRunError (unexpected throw).
  • No module cycleProgressEvent moved to execute/types.ts (single source
    of truth), re-exported from runAll.ts; runListener imports from types.js.
  • Exhaustiveness — a future ProgressEvent variant is a compile error, not a
    silently-dropped event.

Backward compatibility

onProgress fires exactly as before; listeners is purely additive; the
ProgressEvent re-export keeps every existing importer working; the SDK's own
ProgressEvent is untouched.

Scope

This is the create + adopt-in-engine increment. Reporter/telemetry-as-listener
adoption is deliberately deferred (the HTML/JSON report is written outside runAll
today). baselineScanner's two verdict-icon spots were left to avoid a conflict
with in-flight #155 — a small follow-up.

Verification

full suite 124 tests, 0 fail (3 skips) · typecheck 0 · lint 0 · build OK.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added run lifecycle listeners via RunListener, enabling observers for run start, finish, and error events.
    • Added listeners support in run options; progress updates are now delivered to both the existing progress callback and registered listeners.
    • Updated SDK exports so consumers can implement and wire RunListener hooks.
  • Behavior Updates
    • Improved consistency in when run lifecycle hooks fire and when progress/output is emitted during execution.

Introduce RunListener — an Observer over the run lifecycle — so reporters,
progress UIs, and telemetry become listeners and a new output format needs no
engine edits. The engine keeps its single ProgressEvent stream; dispatchProgress
adapts it to the typed hooks.

- core/src/execute/runListener.ts: RunListener interface (all hooks optional) with
  payloads derived from ProgressEvent (Omit<Extract<…>>), so they can't drift; a
  notifyListeners helper that isolates a throwing listener (logged and skipped) so
  a buggy reporter/telemetry adapter can never abort the run; dispatchProgress with
  a compile-time exhaustiveness guard.
- core/src/execute/runAll.ts: listeners?: RunListener[] on RunAllOptions (additive);
  notify fans out to onProgress AND listeners; onRunStart before the loop,
  onRunFinish after the report, and onRunError in a catch so the lifecycle is
  symmetric (onRunStart always pairs with a terminal hook).
- ProgressEvent moves to execute/types.ts (single source of truth) and is
  re-exported from runAll.ts, breaking the runListener<->runAll module cycle while
  keeping every existing importer working.
- runners/cli: ConsoleProgressListener implements RunListener; run.ts swaps its
  inline onProgress switch for listeners: [new ConsoleProgressListener()] — the
  first adopter, proving the pattern end-to-end. Same progress output.
- lib/verdictIcon.ts: shared verdict->glyph helper, replacing copy-pasted ternaries
  in the listener and evaluatorLoop.

Backward-compatible: onProgress fires exactly as before; listeners is additive; the
SDK's own ProgressEvent type is untouched. New tests pin the event->hook mapping and
that a throwing listener is isolated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d704cd22-e787-4dda-90d2-ce0b662a9c2d

📥 Commits

Reviewing files that changed from the base of the PR and between d62623a and 272d7fc.

📒 Files selected for processing (6)
  • core/src/execute/runAll.ts
  • core/src/execute/runListener.ts
  • runners/mcp/src/index.ts
  • runners/sdk/src/index.ts
  • runners/sdk/src/run.ts
  • runners/sdk/src/types.ts
✅ Files skipped from review due to trivial changes (1)
  • runners/sdk/src/index.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • core/src/execute/runListener.ts
  • core/src/execute/runAll.ts

Walkthrough

runAll now accepts lifecycle listeners, routes progress events to them alongside the legacy progress callback, and emits start, finish, and error hooks during execution. The new listener type is re-exported through the SDK, and runner wiring now passes listeners through.

Changes

Run lifecycle listener support

Layer / File(s) Summary
Listener contract and progress dispatch
core/src/execute/runListener.ts
Defines RunListener, notifyListeners, and dispatchProgress for lifecycle and progress hook fan-out.
RunAll listener wiring
core/src/execute/runAll.ts
Adds listeners to RunAllOptions, re-exports ProgressEvent, and forwards progress events to both the legacy callback and listeners while emitting run lifecycle hooks.
SDK and runner propagation
runners/sdk/src/*.ts, runners/mcp/src/index.ts
Re-exports RunListener, adds it to RunOptions, passes listeners through run(), and updates the MCP runner to use listener callbacks for evaluator output.

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

Sequence Diagram(s)

sequenceDiagram
  participant SDK
  participant run
  participant runAll
  participant notifyListeners
  participant dispatchProgress
  participant MCP

  SDK->>run: options.listeners
  run->>runAll: listeners + onProgress
  runAll->>notifyListeners: onRunStart / onRunFinish / onRunError
  runAll->>MCP: connect and trace curation
  runAll->>dispatchProgress: ProgressEvent
  dispatchProgress->>notifyListeners: evaluator/attack hooks
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the new run-listener observer adoption in the CLI.
Description check ✅ Passed The description covers the solution, files changed, compatibility, and verification, though the template headings aren't followed exactly.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor-run-listener

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

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

Comment thread core/src/execute/runAll.ts Outdated
(report as UnifiedRunReport & { stopReason?: string }).stopReason = stopReason;
}

notifyListeners(listeners, (listener) => listener.onRunFinish?.(report));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

buildReport and this finish hook are outside the try/catch, so if report-building throws, a listener gets onRunStart but no finish or error.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in d62623a — report-building and onRunFinish are now inside the try, so onRunError covers a throw from buildReport too. onRunStart is now always paired with exactly one terminal hook.

onAttackDone?(info: Payload<"attack_done">): void;
onEvaluatorDone?(info: Payload<"evaluator_done">): void;
/** Fired when a non-retryable error halts the run gracefully (partial report). */
onRunStopped?(info: Payload<"run_stopped">): void;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

On a stop, listeners get both onRunStopped and then onRunFinish — this one isn't the final signal.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Clarified in d62623a. This is intentional — a reporter should still receive the partial report on a stop — but the docs were misleading. onRunStopped is now documented as a non-terminal notice (onRunFinish still follows with the partial report); onRunFinish/onRunError are the two terminal hooks.

Comment thread core/src/execute/runAll.ts Outdated
// evaluatorCount is the attack-evaluator count; MCP baseline pre-flight scans
// (run below) contribute extra results not reflected here.
notifyListeners(listeners, (listener) =>
listener.onRunStart?.({ evaluatorCount: ordered.length })

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This fires after the MCP connect + trace curation, so anything watching for the run to begin won't see it during those steps.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in d62623aonRunStart now fires at the true start, before the MCP connect and trace curation, so watchers see the run begin during those steps. Everything after it moved inside the try to keep the lifecycle symmetric (and the finally now also closes the MCP target if listTools() throws after connect).

Address review feedback on the RunListener wiring:

- onRunStart now fires at the true start, before the MCP connect and trace
  curation, so watchers see the run begin during those (possibly slow) steps.
- The MCP connect, trace curation, baseline scans, attack loop, AND report
  building now all run inside the try, so onRunError covers a throw from any of
  them — onRunStart is always paired with exactly one terminal hook (previously
  buildReport/onRunFinish sat outside the try and could be skipped on a throw).
- As a side effect the finally now closes the MCP target even when listTools()
  throws after a successful connect (a latent leak).
- Clarify the lifecycle contract in the interface docs: onRunStopped is a
  non-terminal notice (onRunFinish still follows with the partial report);
  onRunFinish and onRunError are the terminal hooks.

Behavior-preserving for the success and graceful-stop paths (smoke/equivalence
green); only the error path and event ordering change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@core/src/execute/runAll.ts`:
- Around line 77-86: Move the lifecycle guard in runAll so resolveModel,
resolveEvaluators, and topoSortEvaluators are inside the same try/catch as the
rest of the run setup, or wrap them with an outer catch, so any pre-run failure
still triggers onRunError and the listener contract is preserved. Keep
onRunStart as the first lifecycle event, but ensure the try begins before the
setup work and that the terminal error hook is reached for every throw.

In `@core/src/execute/runListener.ts`:
- Around line 20-31: `notifyListeners` only guards synchronous throws, so
rejected promises from async hooks can still escape and break listener
isolation. Update the `notifyListeners` flow in `runListener.ts` to await or
otherwise normalize each listener result so both thrown errors and promise
rejections are caught and logged. Keep the warning tied to the specific listener
method being invoked, and make the message actionable by telling the listener
author to fix that hook’s implementation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0d2d8968-ff76-407f-9131-d988a7993ae9

📥 Commits

Reviewing files that changed from the base of the PR and between 93dbd95 and d62623a.

📒 Files selected for processing (2)
  • core/src/execute/runAll.ts
  • core/src/execute/runListener.ts

Comment thread core/src/execute/runAll.ts Outdated
Comment thread core/src/execute/runListener.ts Outdated
…steners

Address review feedback on the RunListener wiring:

- runAll now runs model + evaluator resolution and the topo-sort INSIDE the try, so
  a failure there reaches onRunError too (previously they ran before the try and
  bypassed the terminal hook). onRunStart still fires once the evaluator count is
  known, just before the MCP connect; a setup failure throws before it, so
  listeners get onRunError with no preceding onRunStart — documented in the contract.
- notifyListeners now isolates async listeners: a hook that rejects its returned
  promise (not just a synchronous throw) is caught and skipped, so a buggy
  reporter/telemetry adapter still can't abort the run. The warning is actionable
  ("fix the listener implementation").

Behavior-preserving for successful and graceful-stop runs (smoke/equivalence green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jithin23-kv

Copy link
Copy Markdown
Collaborator

I think we need to do the same in the MCP runner (runners/mcp/src/index.ts) — it has the same inline onProgress switch this PR replaced in the CLI.

The SDK currently only exposes onProgress, which just gives per-attack events, not the run start/finish/error signals RunListener adds — ideally the SDK should expose these too so users can track full run lifecycle.

We can merge this PR and do it separately if neeeded

…he sdk

Extend the RunListener adoption beyond the CLI, per review:

- runners/mcp: the MCP runner had the same inline onProgress switch this PR
  replaced in the CLI. Convert it to a RunListener (onEvaluatorStart /
  onEvaluatorDone) so both runners consume engine events through the SPI.
- runners/sdk: run() only forwarded onProgress (per-attack events). Add a
  listeners?: RunListener[] option, forward it to runAll, and re-export the
  RunListener type so SDK users can observe the full run lifecycle
  (onRunStart / onRunFinish / onRunError), not just per-attack progress.

Additive and backward-compatible: onProgress is unchanged on every path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@prasanth-nair-kv

Copy link
Copy Markdown
Collaborator Author

Both valid — done in 272d7fc.

  • MCP runner: the inline onProgress switch in runners/mcp/src/index.ts is now a RunListener (onEvaluatorStart / onEvaluatorDone), so both runners consume engine events through the SPI.
  • SDK: run() now accepts a listeners?: RunListener[] option (forwarded to runAll), and RunListener is re-exported from the SDK — so SDK users can observe the full run lifecycle (onRunStart / onRunFinish / onRunError), not just per-attack onProgress. Additive and backward-compatible.

@achuvyas-kv achuvyas-kv merged commit 62b8ed1 into master Jul 3, 2026
7 of 8 checks passed
@jithin23-kv jithin23-kv deleted the refactor-run-listener branch July 3, 2026 09:41
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.

3 participants