Skip to content

feat(approval-gate): state-as-truth refactor — direct policy consult + reactive turn_state event + reload recovery - #159

Merged
ytallo merged 33 commits into
mainfrom
feat/approval-gate-direct-policy
May 19, 2026
Merged

feat(approval-gate): state-as-truth refactor — direct policy consult + reactive turn_state event + reload recovery#159
ytallo merged 33 commits into
mainfrom
feat/approval-gate-direct-policy

Conversation

@ytallo

@ytallo ytallo commented May 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Collapses the approval-gate's three-hop hook chain into a direct policy::check_permissions call from the orchestrator, then makes the frontend derive pending-approval modals from the orchestrator's turn_state record via a new reactive turn_state_changed event — so reload-mid-approval re-mounts the modal and the legacy approval_requested / approval_resolved signal events can be deleted entirely.

Backend production code −143 LOC (gate-subscriber, state-bus, hook plumbing, dead types). Frontend gains three small framework-free modules + a stateful translator. End state: state is the source of truth on both sides; events are just notifications.

What changed

Backend (turn-orchestrator + approval-gate):

  • consultBefore calls policy::check_permissions directly via iii.trigger (5s timeout). No more hook-fanout::publish_collect for the before-hook path, no agent::before_function_call topic, no policy::approval_gate durable subscriber. Fail-closed semantics + legacy approval_required fallback preserved.
  • Deleted: approval-gate/gate-subscriber.ts, state-bus.ts, IncomingCall / extractCall / blockReplyFor / GateBlockReply / SUBSCRIBER_NAME wire types, consultPolicy dead export.
  • approval::on_decision_written now triggers turn::step directly (mirrors on-record-written.ts). Both reactive wake paths use the same primitive.
  • STEP_FN_ID = 'turn::step' consolidated to a single canonical definition in subscriber.ts.
  • New on-turn-state-changed.ts state-trigger adapter emits turn_state_changed on every turn_state write — carries the full new (and prior) record so the FE can derive pending approvals from state.

Frontend (console/web):

  • pending-approvals-store.ts — framework-free diff helper keyed by function_call_id.
  • turn-state-mirror.ts — pure reducer that converts a turn_state record into a PendingApproval[].
  • createTurnStateTranslator() factory in translate.ts — stateful per-session mirror, emits fcall-start { pendingApproval: true } exactly once per new pending call (suppresses duplicates on re-broadcast).
  • realStream fires a one-shot state::get on subscribe so modals re-mount after page reload.
  • Removed approval_requested and approval_resolved consumer cases from translate.ts and the matching variants from iii-agent-event.ts. Denial UX continues to flow through function_execution_end (the orchestrator's entry.blockedbuildFunctionExecutionEnd path).

Docs:

  • architecture.md, workers/turn-orchestrator.md, workers/approval-gate.md updated to describe the new flow, the two distinct state triggers (step-waker vs event-emitter), and the reload-recovery contract.

Diff stats

Category + Net
Production code 371 514 −143
Tests 588 351 +237
Docs + YAML 94 101 −7
Total 1052 965 +87

The net +87 is offset entirely by new test coverage for the new derivation chain (6 new test files: on-turn-state-changed, hook, config, pending-approvals-store, turn-state-mirror, translate).

Architectural wins (not visible in LOC)

  • 1 fewer durable subscriber on the bus (policy::approval_gate gone)
  • 1 fewer durable topic in production (agent::before_function_call gone)
  • No agent::hook_reply stream round-trip per approval consult
  • 2 fewer signal events in the wire format
  • Reload-mid-approval now actually re-renders the modal (new capability)
  • Both reactive wake paths share one primitive (iii.trigger('turn::step', ...))

Test plan

  • harness-node suite — 329 tests, all green
  • console/web suite — 173 tests, all green
  • pnpm exec tsc --noEmit clean on both packages
  • E2E approve path verified in browser: modal renders from turn_state_changed, Approve writes the file (/tmp/qa-final-take2.txt = APPROVAL_WORKS_END_TO_END), card flips to ran ƒ shell::exec for 0ms with full response
  • E2E deny path verified: card flips to ran with text: "Permission denied by user: denied", details.approval_denied: true — flowed through function_execution_end blocked-result branch (no approval_resolved event needed)
  • E2E reload recovery verified: modal up → hard browser reload mid-approval → modal automatically re-mounts from state::get → Approve completes → file written
  • grep -rn "approval_requested\|approval_resolved\|handleResolveWithEvents\|gate-subscriber\|agent::before_function_call" harness-node/src console/web/src returns empty (or only in clear "no longer" docs context)

Things to know

  • The branch baseline is the local commit feat(harness-node): complete reactive turn-step wake (PR feat(harness-node): reactive approval resume + orchestrator cleanup #156 follow-up), which adds the on-record-written state trigger this PR builds on.
  • Commit history is preserved (26 commits) rather than squashed — incremental story reads cleanly.
  • One latent issue surfaced during E2E (not blocking, not introduced by this PR): handleExecute's pre_approved branch doesn't catch iii.trigger errors, so a malformed payload from the LLM (e.g., wrong field name on shell::fs::write) can hang the card indefinitely. Worth a follow-up to wrap in try/catch + emit a synthetic function_execution_end with an error envelope.

Summary by CodeRabbit

  • New Features

    • Frontend now recovers and displays pending function approvals on reload; pending approvals persist in state and surface automatically.
  • Improvements

    • Approval flow rewired to use state-driven wakeups, making approvals more reliable and reducing duplicate signals.
    • Orchestrator now consults policy with a bounded timeout for allow/deny/pending decisions, improving fail-closed behavior.
  • Documentation

    • Architecture and worker docs updated to reflect the new approval/state-driven design.

Review Change Stack

ytallo added 26 commits May 18, 2026 20:22
Replace the imperative `turn::step_requested` self-publish with a state
trigger on `session/<id>/turn_state`. Saving the record is now the wake
end-to-end — both the first step (from `run::start`) and every
subsequent transition fire through the same reactive path.

- Register `on-record-written` state trigger in register.ts (mirrors
  the abort + terminal blocks; condition excludes `stopped` and
  `function_awaiting_approval` so we don't loop on parked turns).
- Drop the redundant `publishStep` from run-start.ts (the initial
  `saveRecord(state='provisioning')` now wakes turn::step via the
  trigger; keeping the publish was a double-step on every new session).
  Removes the function, its export, `STEP_TOPIC`, and the `logger`
  import that's now unused.
- Drop the self-publish from subscriber.ts after each transition; also
  drop the dangling `Mirrors turn-orchestrator/src/subscriber.rs`
  comment (Rust workers were removed in 07311d9).
- Refactor on-record-written.ts so the condition and handler share a
  single `parseStepableWrite` — the handler can no longer fire on a
  parking-state write even if the condition is bypassed.
- Remove unconditional `console.log` in transitions.ts.

Tests:
- New unit tests in tests/turn-orchestrator/on-record-written.test.ts
  cover the condition (stepable / parking / terminal / wrong key /
  malformed shape) and the handler (happy path, no-op on key mismatch,
  swallow on iii.trigger failure).
- New integration test in tests/integration/on-record-written.e2e.test.ts
  drives a fake iii through state::set → handler → turn::step and
  verifies stepable wake, multi-transition wake, parking no-wake,
  terminal no-wake, and non-turn_state no-leak.
- Update run-start.test.ts: drop the now-broken
  `iii::durable::publish` assertion, replace it with a positive
  assertion on the `turn_state` state::set that triggers the wake, and
  assert no publish fires.
…hHook

- Widen consultBefore signature with _policy_function_id (5th param, unused until Task 3)
- registerAgentCall now receives orchestratorCfg.policy_function_id
- handleExecute takes cfg: TurnOrchestratorConfig and forwards cfg.policy_function_id to dispatchWithHook
- transitions.ts passes cfg through to handleExecute
- functions.test.ts updated with cfg stub for all handleExecute call sites
…from consultBefore

Replace the hook-fanout::publish_collect before-hook round-trip with a direct
iii.trigger call to the configured policy_function_id. Maps policy replies
(allow/deny/needs_approval) to HookOutcome. Fail-closed on transport error:
deny with gate_unavailable, unless function_id is in the legacy
approval_required list (then pending). Drops merged field from HookOutcome
pending variant. publishAfter still uses hook-fanout (after-hook unchanged).
…ch prior policy timeout

- Remove duplicate DeniedBy/DenialEnvelope/DENIAL_SCHEMA_VERSION from hook.ts; import from approval-gate/types and re-export so agent-call.ts consumers are unaffected
- Drop timeoutMs from HOOK_TIMEOUT_MS (10s) to 5_000 on the policy trigger, matching the prior gate-subscriber → policy hop budget
- Add session_id rationale comment and policy timeout rationale comment
- Fix misleading test name: "substring" → "list" to reflect Array.includes semantics
…proval

The gate-subscriber that previously emitted this event was deleted in
the approval-gate refactor. The web UI still consumes it via
translate.ts to flip the function card into pending-approval mode.
Emit it from handleExecute directly when the dispatch returns pending.
@vercel

vercel Bot commented May 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
workers Ready Ready Preview, Comment May 19, 2026 11:45am

Request Review

@coderabbitai

coderabbitai Bot commented May 19, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@ytallo has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 45 minutes and 27 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b4fb2ba4-dc1a-4ee4-aad1-29e0c4050605

📥 Commits

Reviewing files that changed from the base of the PR and between a1a0aa0 and 8292ad0.

📒 Files selected for processing (6)
  • console/web/src/lib/backend/real.ts
  • harness-node/docs/workers/approval-gate.md
  • harness-node/docs/workers/turn-orchestrator.md
  • harness-node/src/turn-orchestrator/get-state.ts
  • harness-node/src/turn-orchestrator/register.ts
  • harness-node/tests/turn-orchestrator/get-state.test.ts
📝 Walkthrough

Walkthrough

This PR refactors the approval flow architecture by moving approval checking from a durable gate subscriber that intercepts function calls to direct policy consultation in the turn orchestrator, with approval state now driven by state-trigger adapters and observable to the frontend via turn_state_changed events.

Changes

Approval flow refactor: direct policy consult and state-driven pending approvals

Layer / File(s) Summary
Type contracts and data shapes
console/web/src/types/iii-agent-event.ts, harness-node/src/types/agent-event.ts, console/web/src/lib/backend/pending-approvals-store.ts, console/web/src/lib/backend/pending-approvals-store.test.ts, console/web/src/lib/backend/turn-state-mirror.ts, console/web/src/lib/backend/turn-state-mirror.test.ts
Adds turn_state_changed event variant to agent event union. Introduces PendingApproval and PendingDiff types with diffPending utility to compute added/removed entries by comparing function_call_id. Adds pendingApprovalsFromTurnState to extract pending calls from function_awaiting_approval turn state records with validation and filtering.
Frontend pending approval derivation and event routing
console/web/src/lib/backend/translate.ts, console/web/src/lib/backend/translate.test.ts, console/web/src/lib/backend/real.ts
Updates translator to route turn_state_changed events through a stateful createTurnStateTranslator that mirrors pending approvals and emits fcall-start { pendingApproval: true } only for newly added entries, suppressing duplicates. Removes approval_requested/approval_resolved direct translation. Backend recovery step fetches persisted turn_state via state::get and enqueues synthetic turn_state_changed event to initialize approval UI.
Turn orchestrator direct policy consultation
harness-node/src/turn-orchestrator/hook.ts, harness-node/src/turn-orchestrator/agent-call.ts, harness-node/src/turn-orchestrator/config.ts, harness-node/src/turn-orchestrator/states/functions.ts, harness-node/tests/turn-orchestrator/hook.test.ts, harness-node/tests/turn-orchestrator/agent-call.test.ts, harness-node/tests/turn-orchestrator/config.test.ts, harness-node/tests/turn-orchestrator/functions.test.ts
consultBefore now directly calls policy_function_id with 5s timeout instead of fanout-based gate subscription, mapping outcomes to allow/deny/pending. Fail-closed (gate_unavailable) when policy unreachable unless function in legacy approval_required list. policy_function_id threaded through dispatch signatures and configuration loading with default policy::check_permissions.
Approval-gate conversion to state-trigger adapter
harness-node/src/approval-gate/config.ts, harness-node/src/approval-gate/pending.ts, harness-node/src/approval-gate/on-decision-written.ts, harness-node/src/approval-gate/register.ts, harness-node/src/approval-gate/types.ts, harness-node/src/approval-gate/gate-subscriber.ts, harness-node/src/approval-gate/state-bus.ts, harness-node/src/approval-gate/policy-consult.ts, harness-node/tests/approval-gate/pending.test.ts, harness-node/tests/approval-gate/on-decision-written.test.ts, harness-node/tests/approval-gate/types.test.ts
Approval-gate transforms from durable subscriber intercepting calls to state-trigger adapter owning approval resolution. Simplifies config to only approval_state_scope. Removes StateBus abstraction; handleResolve now uses iii.trigger for state::set directly. on-decision-written triggers turn::step function directly instead of publishing durable topic. Deletes gate-subscriber, state-bus, and consultPolicy (now orchestrator responsibility). Tests updated for new ISdk-based mocking.
New state-trigger adapters for orchestration and UI events
harness-node/src/turn-orchestrator/on-record-written.ts, harness-node/src/turn-orchestrator/on-turn-state-changed.ts, harness-node/src/turn-orchestrator/register.ts, harness-node/tests/turn-orchestrator/on-record-written.test.ts, harness-node/tests/turn-orchestrator/on-turn-state-changed.test.ts, harness-node/tests/integration/on-record-written.e2e.test.ts
Adds on-record-written adapter that triggers turn::step on stepable turn_state writes (skipping terminal and function_awaiting_approval states). Adds on-turn-state-changed adapter that emits turn_state_changed agent events with new_value/old_value to frontend for UI-driven pending approval derivation. Both parse generic state write events, extract session_id, and route appropriately with error swallowing. Wired into orchestrator register with comprehensive condition/handler tests.
Remove imperative step-publish mechanism
harness-node/src/turn-orchestrator/run-start.ts, harness-node/src/turn-orchestrator/subscriber.ts, harness-node/tests/turn-orchestrator/run-start.test.ts, harness-node/tests/integration/approval-resume.e2e.test.ts
Removes explicit publishStep() logic and STEP_TOPIC constant that previously triggered durable turn::step_requested. Orchestrator wake now driven reactively by on-record-written state-trigger adapter on stepable writes. Tests updated to verify state::set calls for initial provisioning instead of durable publish expectations and to track stepTriggers instead of generic publishes.
Documentation and configuration updates
harness-node/config.yaml, harness-node/docs/architecture.md, harness-node/docs/workers/approval-gate.md, harness-node/docs/workers/turn-orchestrator.md, harness-node/src/approval-gate/iii.worker.yaml, harness-node/src/approval-gate/main.ts, harness-node/src/index.ts
Architecture docs updated to describe approval-gate as state-trigger adapter waking turn::step on approvals scope writes, orchestrator consulting policy::check_permissions directly, and fail-closed behavior on policy unreachable. Worker descriptions changed from hook-subscriber semantics to state-trigger/resolution semantics. Config.yaml relocates policy_function_id to top-level; approval_gate simplified to only approval_state_scope.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • iii-hq/workers#156: Both PRs implement the same approval-resume UI signaling pipeline by emitting/consuming turn_state_changed events to drive pending-approval derivation; similar frontend and backend translator/adapter changes.

Suggested reviewers

  • sergiofilhowz

Poem

🐰 hop hop — I watched the gate reform,

policy called, not the old perform,
state writes wake the sleeping turn,
pending calls no longer churn,
UI bops when approvals are born.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.89% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main refactoring: state-driven approval handling with direct policy consultation and turn_state event-based recovery for the frontend.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/approval-gate-direct-policy

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 and usage tips.

@github-actions

github-actions Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

skill-check — worker

0 verified, 10 skipped (no docs/).

Layer Result
structure
vale
ai

Three for three. Nicely done.

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
harness-node/src/turn-orchestrator/states/functions.ts (1)

87-97: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle trigger failures in the pre-approved execution path.

Line 88 awaits iii.trigger without a try/catch. If the tool call throws (timeout, function_not_found, transport error), handleExecute exits early and the turn can remain stuck in function_execute without a function_execution_end event for that call.

Suggested fix
     if (entry.pre_approved === true) {
-      const value = await iii.trigger<unknown, unknown>({
-        function_id: fc.function_id,
-        payload: fc.arguments ?? {},
-      });
-      const result = decodeOrPassthroughResult(value);
+      let result: FunctionResult;
+      try {
+        const value = await iii.trigger<unknown, unknown>({
+          function_id: fc.function_id,
+          payload: fc.arguments ?? {},
+        });
+        result = decodeOrPassthroughResult(value);
+      } catch (err) {
+        result = {
+          content: [text(`pre_approved trigger failed: ${String(err)}`)],
+          details: { error: 'trigger_failed', function: fc.function_id, message: String(err) },
+          terminate: false,
+        };
+      }
       const is_error = isErrorResult(result);
       persistence.upsertExecutedCall(results, { function_call: fc, result, is_error });
       await persistence.saveExecutedCalls(iii, rec.session_id, results);
       await emit(iii, rec.session_id, buildFunctionExecutionEnd(fc, result, is_error));
       continue;
🤖 Prompt for 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.

In `@harness-node/src/turn-orchestrator/states/functions.ts` around lines 87 - 97,
The pre-approved execution block calls iii.trigger without error handling; wrap
the iii.trigger(...) await inside a try/catch in the pre_approved branch (where
entry.pre_approved === true) so any thrown error (timeout, function_not_found,
transport error) is caught, convert the caught error into a function result (use
the same shape that decodeOrPassthroughResult/isErrorResult expect, mark
is_error = true), then call persistence.upsertExecutedCall(results, {
function_call: fc, result, is_error }), await persistence.saveExecutedCalls(iii,
rec.session_id, results), and await emit(iii, rec.session_id,
buildFunctionExecutionEnd(fc, result, is_error)) before continuing; ensure the
normal successful path (calling decodeOrPassthroughResult and isErrorResult)
remains unchanged inside the try.
🤖 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 `@harness-node/docs/workers/approval-gate.md`:
- Around line 91-92: The documentation incorrectly states that the config key
`policy_function_id` lives under `approval_gate`; update the text and any
examples so `policy_function_id` is shown and described as a top-level key in
`config.yaml` (not nested under `approval_gate`), and move or reword the bullet
that references it so it appears in the root-level config section; update any
sample YAML blocks and the explanatory sentence that mentions
`consultBefore`/orchestrator to reference the top-level `policy_function_id`
instead of an `approval_gate.policy_function_id`.

In `@harness-node/src/approval-gate/pending.ts`:
- Line 31: pendingKey(session_id, function_call_id) can throw for malformed ids
which currently escapes the function's { ok: false, error: ... } contract; wrap
the call to pendingKey(...) in a try/catch, catch any exception, and return the
standard error shape (e.g., { ok: false, error: err.message || String(err) })
instead of letting the exception bubble, keeping the rest of the function flow
intact and using the same variable name (key) as before.

---

Outside diff comments:
In `@harness-node/src/turn-orchestrator/states/functions.ts`:
- Around line 87-97: The pre-approved execution block calls iii.trigger without
error handling; wrap the iii.trigger(...) await inside a try/catch in the
pre_approved branch (where entry.pre_approved === true) so any thrown error
(timeout, function_not_found, transport error) is caught, convert the caught
error into a function result (use the same shape that
decodeOrPassthroughResult/isErrorResult expect, mark is_error = true), then call
persistence.upsertExecutedCall(results, { function_call: fc, result, is_error
}), await persistence.saveExecutedCalls(iii, rec.session_id, results), and await
emit(iii, rec.session_id, buildFunctionExecutionEnd(fc, result, is_error))
before continuing; ensure the normal successful path (calling
decodeOrPassthroughResult and isErrorResult) remains unchanged inside the try.
🪄 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: 0b875988-92e4-4dab-b1aa-cd34bf54699b

📥 Commits

Reviewing files that changed from the base of the PR and between f623081 and bc59d1b.

📒 Files selected for processing (49)
  • console/web/src/components/chat/ChatView.tsx
  • console/web/src/lib/backend/pending-approvals-store.test.ts
  • console/web/src/lib/backend/pending-approvals-store.ts
  • console/web/src/lib/backend/real.ts
  • console/web/src/lib/backend/translate.test.ts
  • console/web/src/lib/backend/translate.ts
  • console/web/src/lib/backend/turn-state-mirror.test.ts
  • console/web/src/lib/backend/turn-state-mirror.ts
  • console/web/src/types/iii-agent-event.ts
  • harness-node/config.yaml
  • harness-node/docs/architecture.md
  • harness-node/docs/workers/approval-gate.md
  • harness-node/docs/workers/turn-orchestrator.md
  • harness-node/src/approval-gate/config.ts
  • harness-node/src/approval-gate/gate-subscriber.ts
  • harness-node/src/approval-gate/iii.worker.yaml
  • harness-node/src/approval-gate/main.ts
  • harness-node/src/approval-gate/on-decision-written.ts
  • harness-node/src/approval-gate/pending.ts
  • harness-node/src/approval-gate/policy-consult.ts
  • harness-node/src/approval-gate/register.ts
  • harness-node/src/approval-gate/state-bus.ts
  • harness-node/src/approval-gate/types.ts
  • harness-node/src/index.ts
  • harness-node/src/runtime/state.ts
  • harness-node/src/turn-orchestrator/agent-call.ts
  • harness-node/src/turn-orchestrator/config.ts
  • harness-node/src/turn-orchestrator/hook.ts
  • harness-node/src/turn-orchestrator/on-record-written.ts
  • harness-node/src/turn-orchestrator/on-turn-state-changed.ts
  • harness-node/src/turn-orchestrator/register.ts
  • harness-node/src/turn-orchestrator/run-start.ts
  • harness-node/src/turn-orchestrator/states/functions.ts
  • harness-node/src/turn-orchestrator/subscriber.ts
  • harness-node/src/turn-orchestrator/transitions.ts
  • harness-node/src/types/agent-event.ts
  • harness-node/tests/approval-gate/gate-subscriber.test.ts
  • harness-node/tests/approval-gate/on-decision-written.test.ts
  • harness-node/tests/approval-gate/pending.test.ts
  • harness-node/tests/approval-gate/types.test.ts
  • harness-node/tests/integration/approval-resume.e2e.test.ts
  • harness-node/tests/integration/on-record-written.e2e.test.ts
  • harness-node/tests/turn-orchestrator/agent-call.test.ts
  • harness-node/tests/turn-orchestrator/config.test.ts
  • harness-node/tests/turn-orchestrator/functions.test.ts
  • harness-node/tests/turn-orchestrator/hook.test.ts
  • harness-node/tests/turn-orchestrator/on-record-written.test.ts
  • harness-node/tests/turn-orchestrator/on-turn-state-changed.test.ts
  • harness-node/tests/turn-orchestrator/run-start.test.ts
💤 Files with no reviewable changes (7)
  • harness-node/src/approval-gate/config.ts
  • harness-node/src/approval-gate/state-bus.ts
  • harness-node/src/turn-orchestrator/run-start.ts
  • console/web/src/components/chat/ChatView.tsx
  • harness-node/tests/approval-gate/gate-subscriber.test.ts
  • harness-node/src/approval-gate/gate-subscriber.ts
  • harness-node/src/approval-gate/types.ts

Comment thread harness-node/docs/workers/approval-gate.md Outdated
@@ -35,50 +31,13 @@ export async function handleResolve(
const key = pendingKey(session_id, function_call_id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard pendingKey(...) exceptions to preserve the function’s error contract.

On Line 31, pendingKey(session_id, function_call_id) can throw for malformed ids (e.g., containing /), which bypasses your { ok: false, error: ... } response flow and bubbles as an unhandled error.

Suggested fix
-  const key = pendingKey(session_id, function_call_id);
+  let key = '';
+  try {
+    key = pendingKey(session_id, function_call_id);
+  } catch {
+    return { ok: false, error: 'missing_id' };
+  }
🤖 Prompt for 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.

In `@harness-node/src/approval-gate/pending.ts` at line 31, pendingKey(session_id,
function_call_id) can throw for malformed ids which currently escapes the
function's { ok: false, error: ... } contract; wrap the call to pendingKey(...)
in a try/catch, catch any exception, and return the standard error shape (e.g.,
{ ok: false, error: err.message || String(err) }) instead of letting the
exception bubble, keeping the rest of the function flow intact and using the
same variable name (key) as before.

…e turn_state wakes

Two P1 findings from Codex review:

1. hook.ts:88-89 (permissions bypass): the needs_approval branch returned
   allow when approval_required was non-empty and the function wasn't in
   the list. That meant a run with approval_required=['shell::run'] let
   every other no-rule function execute without approval. The legacy list
   was only ever meant to be consulted in the fail-closed catch path when
   the policy worker is unreachable. Remove the bypass: when policy returns
   needs_approval, always pending.

2. on-record-written.ts:47-53 (same-state wakes): the condition accepted
   any non-terminal, non-parking turn_state write. handlePrepare calls
   saveRecord while the record is still in function_prepare to persist
   normalized calls; that same-state write was waking turn::step and
   racing the in-flight handler. Filter state:updated writes whose
   old_value.state equals new_value.state.

Both regressions guarded by new tests.
After the policy-direct refactor, approval_required was consulted in
exactly one place (consultBefore's catch path when policy is unreachable)
and the FE stopped sending it. So the parameter was dead pipeline:
parsed in run-start.ts, loaded in functions.ts, threaded through
agent-call.ts to consultBefore, used only in a branch that effectively
never fires in production.

Drop it end-to-end. The fail-closed branch now unconditionally returns
deny with a gate_unavailable envelope when policy is unreachable. No
caller-list escape valve, no per-run allowlist sneaking around the
policy. Net: -82 LOC, simpler dispatchWithHook signature, simpler
catch path, simpler run::start payload contract.

Tests updated: dropped the 'legacy approval_required list when policy
unreachable' and 'non-empty approval_required omits this function' test
cases. fails-closed test is the sole guard for the unreachable path.
Docs in architecture.md and workers/turn-orchestrator.md no longer
mention the legacy list.
1. run-start.ts: save the initial turn record AFTER emitting agent_start
   and the user-message prelude. on-record-written wakes turn::step
   reactively, so saving first lets the FSM race ahead of the prelude
   loop and the subscriber sees provisioning-state events before
   agent_start. Reordering closes the race.

2. docs/workers/approval-gate.md: stop documenting
   approval_gate.policy_function_id — the field was removed from
   ApprovalGateConfig in a prior commit, so an operator setting it
   would have it silently ignored. The active key is top-level
   policy_function_id, owned by the orchestrator's config slice.

P2 #1 from the re-review (preserve legacy approval_required) is
obsoleted by the prior 'drop legacy approval_required parameter'
commit, which removes the parameter end-to-end.
This commit cleans up the `hook.ts` and `register.ts` files by removing obsolete comments that no longer provide value. The changes enhance code readability and maintainability, streamlining the overall implementation without affecting functionality.
…ilure

Codex P1: replacing the durable publish with a direct iii.trigger to
turn::step removed the retry safety net. If the direct invoke times
out or throws after the triggering state write already landed, the
state-trigger handler succeeds but no turn::step_requested message
exists for the durable subscriber to retry — the session sits stuck
in that state forever.

Restore the safety net by falling back to iii::durable::publish on
turn::step_requested when the direct invoke rejects. Direct trigger
stays the fast path; the durable bus is the fallback that buffers and
retries. If the publish fallback also fails, log error and exit (best
effort fail-closed — caller can recover via abort or restart).

Also export STEP_TOPIC from subscriber.ts so on-record-written can
reuse it without duplicating the string literal.

Tests:
- New: 'falls back to durable publish when the direct turn::step
  invoke fails' asserts both the direct attempt and the durable
  publish payload shape.
- Renamed: 'swallows when BOTH the direct invoke and durable publish
  fallback fail' makes the existing log-only behaviour explicit.
Comment on lines +117 to +138
client
.call<Record<string, unknown> | null>('state::get', {
scope: 'agent',
key: `session/${sessionId}/turn_state`,
})
.then((record) => {
if (!record) return
queue.push({
type: 'turn_state_changed',
event_type: 'state:created',
new_value: record,
})
wake()
})
.catch((err) => {
if (import.meta.env.DEV) {
console.warn(
'[real-backend] state::get turn_state recovery failed',
err,
)
}
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i think we shouldn't call state directly from the frontend, it should be abstracted on the harness logic

@ytallo ytallo May 19, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good call. Added turn::get_state (harness-node/src/turn-orchestrator/get-state.ts) that wraps persistence.loadRecord, and the console now calls it instead of state::get for reload recovery. Schema and key layout stay inside the orchestrator. Pushed in 8292ad0.

…hook tail latency

The pre_approved branch in handleExecute did not catch the inner
iii.trigger rejection, so a bad-args dispatch (e.g. shell::fs::write
called with a raw string for `content`) propagated out of the state
machine, leaving the turn stuck in function_execute. The reactive
durable-retry then re-published the same failing transition forever,
and the UI never received function_execution_end. Wrap the trigger
and synthesize a trigger_failed FunctionResult so the card closes
and the LLM sees the error.

Also drop HOOK_TIMEOUT_MS from 10_000ms to 500ms. The after-hook
fanout on agent::after_function_call has zero subscribers after this
PR removed gate-subscriber, so publish_collect always hits the
deadline; per-call finalize was eating the full 10s.

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
harness-node/docs/workers/approval-gate.md (1)

58-59: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix relative markdown links; several targets appear broken from this directory.

From harness-node/docs/workers/approval-gate.md, links like harness-node/src/... and workers/turn-orchestrator.md don’t resolve correctly relative to this file’s location.

Suggested patch pattern
-[config.yaml](harness-node/config.yaml)
+[config.yaml](../../config.yaml)

-[workers/turn-orchestrator.md](workers/turn-orchestrator.md)
+[workers/turn-orchestrator.md](turn-orchestrator.md)

-[src/approval-gate/types.ts](harness-node/src/approval-gate/types.ts)
+[src/approval-gate/types.ts](../../src/approval-gate/types.ts)

-[src/approval-gate/main.ts](harness-node/src/approval-gate/main.ts)
+[src/approval-gate/main.ts](../../src/approval-gate/main.ts)

Also applies to: 85-86, 93-94, 103-118

🤖 Prompt for 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.

In `@harness-node/docs/workers/approval-gate.md` around lines 58 - 59, The
markdown in harness-node/docs/workers/approval-gate.md contains broken relative
links (e.g., references to harness-node/src/approval-gate/types.ts,
harness-node/src/approval-gate/pending.ts, and workers/turn-orchestrator.md)
that do not resolve from this doc's location; update each link to use the
correct relative path from docs/workers/approval-gate.md (for example, adjust
"harness-node/src/..." to "../../src/approval-gate/types.ts" or the appropriate
relative traversal) and fix other referenced targets on the same file (lines
around the occurrences such as the types.ts, pending.ts, and
turn-orchestrator.md links) so they point to the actual files in the repo tree
rather than a top-level harness-node prefix.
🧹 Nitpick comments (2)
console/web/src/lib/backend/real.ts (1)

117-138: 💤 Low value

Fire-and-forget recovery call may silently fail in production.

The state::get call for turn_state recovery only logs errors in DEV mode. While this is reasonable for a best-effort recovery path, consider whether silent failures in production could make debugging reload-recovery issues difficult.

If recovery failures are expected to be rare and non-critical, this is acceptable. Otherwise, consider at least emitting a metric or structured log entry (not just console.warn) for observability.

🤖 Prompt for 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.

In `@console/web/src/lib/backend/real.ts` around lines 117 - 138, The
fire-and-forget recovery call using client.call('state::get', { key:
`session/${sessionId}/turn_state` }) only logs errors under import.meta.env.DEV;
update the catch handler on that promise to also emit a production-friendly
observability signal (e.g., structured logger or metrics) when the call fails so
failures aren’t silent in prod—modify the .catch(err => { ... }) block that
currently does console.warn to call your app logger/metrics emitter with context
(sessionId, operation: 'state::get', resource: 'turn_state', and the error),
while keeping the DEV console.warn and still not throwing from queue.push/wake.
harness-node/src/turn-orchestrator/states/functions.ts (1)

150-150: 💤 Low value

Stray whitespace on line 150.

Line 150 appears to contain only trailing whitespace. Consider removing it for cleanliness.

🤖 Prompt for 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.

In `@harness-node/src/turn-orchestrator/states/functions.ts` at line 150, Remove
the stray whitespace-only line inside the states functions module (functions.ts)
by deleting the empty/trailing-whitespace-only line so the file has no blank
line containing only spaces; this is a simple cleanup in the turn-orchestrator
states module (functions.ts) to satisfy linting and cleanliness checks.
🤖 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 `@console/web/src/lib/backend/translate.ts`:
- Around line 170-189: The createTurnStateTranslator function creates a
long-lived mirrors Map that never releases per-session entries; update it so
session data is cleared when a stream ends or when an agent_end event is seen:
modify createTurnStateTranslator to either (A) return an object with a
translate(event, sessionId) method plus a cleanup(sessionId) method (or a
translate signature that accepts an options flag like {cleanup:true}), or (B)
detect agent_end inside the translate function (checking event.new_value for
agent_end) and call mirrors.delete(sessionId). Reference
createTurnStateTranslator, mirrors, pendingApprovalsFromTurnState, and
TurnStateChangedEvent; ensure cleanup is invoked when processing
agent_end/stream termination to avoid unbounded Map growth.

In `@harness-node/src/turn-orchestrator/hook.ts`:
- Around line 58-61: The denial payload currently embeds String(err) (in the
gateUnavailableEnvelope call with function_call.function_id), which can leak
internal transport/runtime details; instead log the raw err using the existing
logger (or console.error) and replace the reason passed into
gateUnavailableEnvelope with a stable public message like "policy engine
unreachable" or "upstream service unavailable" so only non-sensitive information
is returned in denial responses. Ensure you modify the
gateUnavailableEnvelope(...) invocation to use the stable message while adding a
logger.error(err, "...context...") nearby to record full details.

---

Outside diff comments:
In `@harness-node/docs/workers/approval-gate.md`:
- Around line 58-59: The markdown in harness-node/docs/workers/approval-gate.md
contains broken relative links (e.g., references to
harness-node/src/approval-gate/types.ts,
harness-node/src/approval-gate/pending.ts, and workers/turn-orchestrator.md)
that do not resolve from this doc's location; update each link to use the
correct relative path from docs/workers/approval-gate.md (for example, adjust
"harness-node/src/..." to "../../src/approval-gate/types.ts" or the appropriate
relative traversal) and fix other referenced targets on the same file (lines
around the occurrences such as the types.ts, pending.ts, and
turn-orchestrator.md links) so they point to the actual files in the repo tree
rather than a top-level harness-node prefix.

---

Nitpick comments:
In `@console/web/src/lib/backend/real.ts`:
- Around line 117-138: The fire-and-forget recovery call using
client.call('state::get', { key: `session/${sessionId}/turn_state` }) only logs
errors under import.meta.env.DEV; update the catch handler on that promise to
also emit a production-friendly observability signal (e.g., structured logger or
metrics) when the call fails so failures aren’t silent in prod—modify the
.catch(err => { ... }) block that currently does console.warn to call your app
logger/metrics emitter with context (sessionId, operation: 'state::get',
resource: 'turn_state', and the error), while keeping the DEV console.warn and
still not throwing from queue.push/wake.

In `@harness-node/src/turn-orchestrator/states/functions.ts`:
- Line 150: Remove the stray whitespace-only line inside the states functions
module (functions.ts) by deleting the empty/trailing-whitespace-only line so the
file has no blank line containing only spaces; this is a simple cleanup in the
turn-orchestrator states module (functions.ts) to satisfy linting and
cleanliness checks.
🪄 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: 20157e9c-4a6e-4fba-b820-14cd42591cc5

📥 Commits

Reviewing files that changed from the base of the PR and between bc59d1b and a1a0aa0.

📒 Files selected for processing (16)
  • console/web/src/lib/backend/real.ts
  • console/web/src/lib/backend/translate.ts
  • harness-node/docs/architecture.md
  • harness-node/docs/workers/approval-gate.md
  • harness-node/docs/workers/turn-orchestrator.md
  • harness-node/src/turn-orchestrator/agent-call.ts
  • harness-node/src/turn-orchestrator/hook.ts
  • harness-node/src/turn-orchestrator/on-record-written.ts
  • harness-node/src/turn-orchestrator/register.ts
  • harness-node/src/turn-orchestrator/run-start.ts
  • harness-node/src/turn-orchestrator/states/functions.ts
  • harness-node/src/turn-orchestrator/subscriber.ts
  • harness-node/tests/turn-orchestrator/agent-call.test.ts
  • harness-node/tests/turn-orchestrator/functions.test.ts
  • harness-node/tests/turn-orchestrator/hook.test.ts
  • harness-node/tests/turn-orchestrator/on-record-written.test.ts
✅ Files skipped from review due to trivial changes (1)
  • harness-node/docs/architecture.md
🚧 Files skipped from review as they are similar to previous changes (5)
  • harness-node/tests/turn-orchestrator/agent-call.test.ts
  • harness-node/tests/turn-orchestrator/on-record-written.test.ts
  • harness-node/docs/workers/turn-orchestrator.md
  • harness-node/src/turn-orchestrator/register.ts
  • harness-node/src/turn-orchestrator/run-start.ts

Comment on lines +170 to +189
export function createTurnStateTranslator(): (
event: TurnStateChangedEvent,
sessionId: string,
) => StreamEvent[] {
const mirrors = new Map<string, PendingApproval[]>()
return (event, sessionId) => {
const prev = mirrors.get(sessionId) ?? []
const next = pendingApprovalsFromTurnState(event.new_value)
mirrors.set(sessionId, next)
const { added } = diffPending(prev, next)
return added.map((entry) => ({
kind: 'fcall-start' as const,
functionId: entry.function_id,
input: entry.args,
pendingApproval: true,
functionCallId: entry.function_call_id,
sessionId,
}))
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Unbounded growth of mirrors Map across sessions.

The mirrors Map accumulates entries for each session but is never cleaned up. In a long-running browser tab with multiple chat sessions, this could cause memory growth.

Consider clearing the session entry when agent_end is received, or implementing a cleanup mechanism when the stream terminates.

🛠️ Suggested approach

Either expose a cleanup method from the translator, or clear entries reactively:

 export function createTurnStateTranslator(): (
   event: TurnStateChangedEvent,
   sessionId: string,
+  opts?: { cleanup?: boolean },
 ) => StreamEvent[] {
   const mirrors = new Map<string, PendingApproval[]>()
-  return (event, sessionId) => {
+  return (event, sessionId, opts) => {
+    if (opts?.cleanup) {
+      mirrors.delete(sessionId)
+      return []
+    }
     const prev = mirrors.get(sessionId) ?? []

Then call with { cleanup: true } when processing agent_end.

🤖 Prompt for 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.

In `@console/web/src/lib/backend/translate.ts` around lines 170 - 189, The
createTurnStateTranslator function creates a long-lived mirrors Map that never
releases per-session entries; update it so session data is cleared when a stream
ends or when an agent_end event is seen: modify createTurnStateTranslator to
either (A) return an object with a translate(event, sessionId) method plus a
cleanup(sessionId) method (or a translate signature that accepts an options flag
like {cleanup:true}), or (B) detect agent_end inside the translate function
(checking event.new_value for agent_end) and call mirrors.delete(sessionId).
Reference createTurnStateTranslator, mirrors, pendingApprovalsFromTurnState, and
TurnStateChangedEvent; ensure cleanup is invoked when processing
agent_end/stream termination to avoid unbounded Map growth.

Comment on lines 58 to 61
denial: gateUnavailableEnvelope(
function_call.function_id,
`permission gate did not respond: ${String(err)}`,
`policy unreachable: ${String(err)}`,
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid returning raw transport error details in denial payloads.

reason includes String(err), which can leak internal runtime/network details via blocked results. Log the raw error, but return a stable public message in the envelope.

Suggested patch
     return {
       kind: 'deny',
       denial: gateUnavailableEnvelope(
         function_call.function_id,
-        `policy unreachable: ${String(err)}`,
+        'policy unreachable',
       ),
     };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
denial: gateUnavailableEnvelope(
function_call.function_id,
`permission gate did not respond: ${String(err)}`,
`policy unreachable: ${String(err)}`,
),
return {
kind: 'deny',
denial: gateUnavailableEnvelope(
function_call.function_id,
'policy unreachable',
),
};
🤖 Prompt for 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.

In `@harness-node/src/turn-orchestrator/hook.ts` around lines 58 - 61, The denial
payload currently embeds String(err) (in the gateUnavailableEnvelope call with
function_call.function_id), which can leak internal transport/runtime details;
instead log the raw err using the existing logger (or console.error) and replace
the reason passed into gateUnavailableEnvelope with a stable public message like
"policy engine unreachable" or "upstream service unavailable" so only
non-sensitive information is returned in denial responses. Ensure you modify the
gateUnavailableEnvelope(...) invocation to use the stable message while adding a
logger.error(err, "...context...") nearby to record full details.

…t from console

Console previously called `state::get { scope: 'agent', key:
'session/<sid>/turn_state' }` directly from the browser for reload
recovery, which leaks the orchestrator's state schema and key
layout into the frontend. Add a `turn::get_state` orchestrator
function that wraps `persistence.loadRecord` and have the console
call it instead. Schema/key changes now stay inside the worker.
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