Skip to content

hooks: address henrypark approval-with-followup items from #3911/#3912/#3913 - #3921

Merged
zmanian merged 4 commits into
reborn-integrationfrom
hooks-henrypark-followup-sweep
May 23, 2026
Merged

zmanian merged 4 commits into
reborn-integrationfrom
hooks-henrypark-followup-sweep

Conversation

@zmanian

@zmanian zmanian commented May 23, 2026

Copy link
Copy Markdown
Collaborator

Consolidates henrypark133 approval-with-followup feedback from three merged hooks PRs. Each item is in its own commit; the only behavioral change is the M1 observer-loss fix on #3911.

#3911 (merged)

  • M1 — fix(hooks): fire AfterCapability observer for hook-suspended entries after allowed entries. In HookedLoopCapabilityPort::invoke_capability_batch, the merge loop initialized stopped_on_suspension from stopped_in_preflight and broke after the first iteration. When the batch was [A=Pending(hook-allowed), B=Resolved(hook-suspension)] with stop_on_first_suspension = true, slot B's AfterCapability observer never fired and B never surfaced in outcomes — violating the per-entry observer contract from PR feat(reborn): add ironclaw_hooks framework foundation (#3524) #3573 (serrrfirat P2 Onboarding: show Telegram in channel selection and auto-install bundled channel #3). Fix: keep walking the slot vec so every observer fires and every Resolved outcome is pushed; only Pending slots are dropped after a stop. (Commit 3a077e9.)

  • M2 — test: new regression batch_invocation_fires_observer_for_hook_suspended_entry_after_allowed_entry_with_stop_on_first_suspension verifies the fix and the mixed-batch contract end-to-end. Written TDD: confirmed the test fails on the pre-fix code (outcomes.len() == 1) before the merge-loop change was applied. (Commit 3a077e9.)

#3912 (merged) — commit 471cf0f

  • L1: removed the unreachable if self.id.as_str().is_empty() guard in HookManifestEntry::validate — HookLocalId::new now rejects empty strings at construction, so manifest deserialization fails before validate() runs.
  • L2: added impl AsRef<str> and impl From<Self> for String to ExtensionId and HookLocalId, plus a canonical into_inner() method, matching the newtype template in .claude/rules/types.md. into_string() is retained as a thin alias so no downstream callers break; new code should use into_inner().
  • L3: added a comment in builtin_id_distinct_from_extension_id documenting why the test fixture substitutes "path.module" for the original "path::module" (the post-refactor(hooks): enforce identity newtype validation at construction #3912 segment grammar disallows : in HookLocalId).

#3913 (merged)

  • L1 — perf(hooks): reuse serialized argument bytes in lazy resolve. resolve_arguments previously called serde_json::to_vec(&value) purely to measure the payload's byte length and then dropped the Vec<u8>; SanitizedArguments::from_json does not re-serialize on the happy path. Replaced with a counting io::Write adapter (serialized_len) that streams serde_json::to_writer into a u64 counter — saves one Vec allocation + matching drop per resolved invocation. Behavior identical: same JSON encoding rules, same cap check, same fail-closed on serialization error. (Commit e6530a8.)

  • L2 — test: new before_capability_needs_input_returns_true_when_any_active_binding_needs_input installs two BeforeCapability bindings on the same scope (one needs_input()=false, one needs_input()=true), then asserts (a) the dispatcher's probe returns true and (b) driving invoke_capability with an instrumented ProbingResolver causes resolve to be called exactly once — pinning the short-circuit through the call site, not just the helper. (Commit bf12ce9.)

Test plan

  • cargo fmt --all
  • cargo clippy --all --benches --tests --examples --all-features -- -D warnings
  • cargo test -p ironclaw_hooks -p ironclaw_reborn
    • ironclaw_hooks: 213 passed (was 211; +1 M1 regression, +1 L2 short-circuit test)
    • ironclaw_reborn: all green
  • TDD verified for M1: the new M2 test fails on the pre-fix merge-loop code (outcomes.len() = 1, expected 2) and passes after the fix.

Notes / out of scope

  • The original L1 task description for perf(hooks): defer capability input resolution until a predicate needs it #3913 suggested adding a SanitizedArguments::from_json_bytes constructor that reuses the measured bytes. After tracing the code path, the existing from_json sanitizes the in-memory serde_json::Value directly (no re-serialize), so the actual perf win was avoiding the throwaway Vec<u8> — implemented via the counting-writer instead, which is local to capability_port.rs and avoids churning SanitizedArguments' API.
  • ExtensionId::into_string and HookLocalId::into_string are kept as aliases. No callers currently use them, but the alias is the conservative path; a follow-up could mark them #[deprecated] or delete them outright once the rest of the workspace finishes migrating to into_inner.

zmanian added 4 commits May 22, 2026 22:05
Address henrypark133 approval-with-followup items from PR #3912:

- L1: remove dead empty-id guard in HookManifestEntry::validate.
  HookLocalId::new now rejects empty strings at construction, so
  manifest deserialization fails before validate() is ever called.
- L2: add AsRef<str>, From<Self> for String, and into_inner() to
  ExtensionId and HookLocalId per the canonical newtype template in
  .claude/rules/types.md. into_string() is retained as a thin alias
  for compatibility; new code should prefer into_inner().
- L3: document why the builtin_id_distinct_from_extension_id test
  fixture substitutes "path.module" for the original "path::module"
  (the new grammar rejects colons in HookLocalId).

No behavioral change.
…after allowed entries

henrypark133 M1 on PR #3911: in `HookedLoopCapabilityPort::invoke_capability_batch`,
when Phase 1 produced a mix of Pending (hook-allowed) and Resolved
(hook-suspension) slots with the suspension appearing AFTER an allowed
entry and `stop_on_first_suspension = true`, the merge loop initialized
`stopped_on_suspension` from `stopped_in_preflight` and broke after the
very first iteration. Result: trailing Resolved suspension slots never
fired their `AfterCapability` observer and never surfaced in the merged
`outcomes` vec, violating the per-entry observer contract from PR #3573
(serrrfirat P2 #3).

Fix: continue iterating the merge loop so every slot fires its observer
and every Resolved outcome is pushed. Only Pending slots are dropped
after a stop (their inner work was already short-circuited in Phase 1
or by an early inner-port stop), tracked via `pending_after_stop`.
Re-pop guard on `inner_outcomes.pop()` keeps the previous "inner
stopped early on its own suspension" semantics: pending slots without
an inner outcome are dropped, but the loop continues so any trailing
Resolved observers still fire.

New regression test
`batch_invocation_fires_observer_for_hook_suspended_entry_after_allowed_entry_with_stop_on_first_suspension`
pins the behavior: `[alpha=hook-allowed, beta=hook-suspension]` with
`stop_on_first_suspension = true` produces a 2-entry `outcomes` vec
(Completed alpha + ApprovalRequired beta), fires the observer twice,
and only sends alpha to the inner port. Verified TDD-style: the test
fails on the pre-fix code with `outcomes.len() = 1`.
henrypark133 L1 on PR #3913: `resolve_arguments` measured the
post-resolver JSON payload by calling `serde_json::to_vec(&value)`
and discarding the `Vec<u8>` once its length was checked. The
`SanitizedArguments::from_json` constructor on the happy path
sanitizes the in-memory `serde_json::Value` directly without
re-serializing, so the materialized buffer was pure overhead.

Switch the size measurement to `serialized_len`, a counting
`io::Write` adapter that streams `serde_json::to_writer` into a u64
counter — saves one Vec<u8> allocation and the matching drop per
resolved invocation. Behavior is identical: the same JSON
encoding rules drive both writers, the cap check still fires when
the encoded length exceeds `MAX_PREDICATE_INPUT_BYTES`, and
serialization errors still fail closed.

No new tests required; existing
`dispatch_fails_closed_when_input_exceeds_max_bytes` and the
ordering / lazy-probe tests exercise this path.
henrypark133 L2 on PR #3913: add
`before_capability_needs_input_returns_true_when_any_active_binding_needs_input`.
Installs two BeforeCapability bindings on the same scope (Global) — one
`needs_input() = false`, one `needs_input() = true` — and asserts both
ends of the short-circuit:

  1. `HookDispatcher::before_capability_needs_input(None)` returns true.
  2. Driving `HookedLoopCapabilityPort::invoke_capability` with an
     instrumented `ProbingResolver` confirms the resolver IS consulted
     exactly once — i.e. the short-circuit fires through the call site,
     not just the helper.

This pins the "any input-needing binding wins" contract end-to-end so a
future change to the dispatcher's probe (or to the middleware's
lazy-probe gate) can't silently regress to short-circuiting on the
first binding only.

Also tightens the merge-loop's pending-slot drop path to use
`Option::map` (clippy::manual_map) — cosmetic, no behavior change.
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@github-actions github-actions Bot added size: L 200-499 changed lines risk: low Changes to docs, tests, or low-risk modules contributor: core 20+ merged PRs labels May 23, 2026
@zmanian
zmanian merged commit aeaa694 into reborn-integration May 23, 2026
14 checks passed
@zmanian
zmanian deleted the hooks-henrypark-followup-sweep branch May 23, 2026 05:24

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bf12ce9524

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +423 to +428
if pending_after_stop {
// We already stopped on a prior suspension and
// queued no work for the inner port past that
// point. A trailing Pending slot has no outcome
// to surface; drop it.
None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Drop all post-suspension slots in sequential batch merge

When stop_on_first_suspension is true, this merge logic only suppresses later Pending slots but still emits later Resolved slots. In a mixed batch where an earlier hook-allowed call suspends in the inner port and a later invocation was already preflight-resolved (for example Denied), the later outcome is still returned even though sequential-stop semantics should truncate the batch at the first suspension. This diverges from the sequential behavior used by other batch ports and can change loop behavior (e.g., GateOutcome::SkipAndContinue can cause those post-suspension outcomes to be processed). After the first suspension is observed, remaining slots should be dropped uniformly (except the suspension slot itself, whose observer should still fire).

Useful? React with 👍 / 👎.

theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…nearai#3912/nearai#3913 (nearai#3921)

* refactor(hooks): align ExtensionId/HookLocalId with newtype template

Address henrypark133 approval-with-followup items from PR nearai#3912:

- L1: remove dead empty-id guard in HookManifestEntry::validate.
  HookLocalId::new now rejects empty strings at construction, so
  manifest deserialization fails before validate() is ever called.
- L2: add AsRef<str>, From<Self> for String, and into_inner() to
  ExtensionId and HookLocalId per the canonical newtype template in
  .claude/rules/types.md. into_string() is retained as a thin alias
  for compatibility; new code should prefer into_inner().
- L3: document why the builtin_id_distinct_from_extension_id test
  fixture substitutes "path.module" for the original "path::module"
  (the new grammar rejects colons in HookLocalId).

No behavioral change.

* fix(hooks): fire AfterCapability observer for hook-suspended entries after allowed entries

henrypark133 M1 on PR nearai#3911: in `HookedLoopCapabilityPort::invoke_capability_batch`,
when Phase 1 produced a mix of Pending (hook-allowed) and Resolved
(hook-suspension) slots with the suspension appearing AFTER an allowed
entry and `stop_on_first_suspension = true`, the merge loop initialized
`stopped_on_suspension` from `stopped_in_preflight` and broke after the
very first iteration. Result: trailing Resolved suspension slots never
fired their `AfterCapability` observer and never surfaced in the merged
`outcomes` vec, violating the per-entry observer contract from PR nearai#3573
(serrrfirat P2 #3).

Fix: continue iterating the merge loop so every slot fires its observer
and every Resolved outcome is pushed. Only Pending slots are dropped
after a stop (their inner work was already short-circuited in Phase 1
or by an early inner-port stop), tracked via `pending_after_stop`.
Re-pop guard on `inner_outcomes.pop()` keeps the previous "inner
stopped early on its own suspension" semantics: pending slots without
an inner outcome are dropped, but the loop continues so any trailing
Resolved observers still fire.

New regression test
`batch_invocation_fires_observer_for_hook_suspended_entry_after_allowed_entry_with_stop_on_first_suspension`
pins the behavior: `[alpha=hook-allowed, beta=hook-suspension]` with
`stop_on_first_suspension = true` produces a 2-entry `outcomes` vec
(Completed alpha + ApprovalRequired beta), fires the observer twice,
and only sends alpha to the inner port. Verified TDD-style: the test
fails on the pre-fix code with `outcomes.len() = 1`.

* perf(hooks): reuse serialized argument bytes in lazy resolve

henrypark133 L1 on PR nearai#3913: `resolve_arguments` measured the
post-resolver JSON payload by calling `serde_json::to_vec(&value)`
and discarding the `Vec<u8>` once its length was checked. The
`SanitizedArguments::from_json` constructor on the happy path
sanitizes the in-memory `serde_json::Value` directly without
re-serializing, so the materialized buffer was pure overhead.

Switch the size measurement to `serialized_len`, a counting
`io::Write` adapter that streams `serde_json::to_writer` into a u64
counter — saves one Vec<u8> allocation and the matching drop per
resolved invocation. Behavior is identical: the same JSON
encoding rules drive both writers, the cap check still fires when
the encoded length exceeds `MAX_PREDICATE_INPUT_BYTES`, and
serialization errors still fail closed.

No new tests required; existing
`dispatch_fails_closed_when_input_exceeds_max_bytes` and the
ordering / lazy-probe tests exercise this path.

* test(hooks): cover mixed needs_input hook binding short-circuit

henrypark133 L2 on PR nearai#3913: add
`before_capability_needs_input_returns_true_when_any_active_binding_needs_input`.
Installs two BeforeCapability bindings on the same scope (Global) — one
`needs_input() = false`, one `needs_input() = true` — and asserts both
ends of the short-circuit:

  1. `HookDispatcher::before_capability_needs_input(None)` returns true.
  2. Driving `HookedLoopCapabilityPort::invoke_capability` with an
     instrumented `ProbingResolver` confirms the resolver IS consulted
     exactly once — i.e. the short-circuit fires through the call site,
     not just the helper.

This pins the "any input-needing binding wins" contract end-to-end so a
future change to the dispatcher's probe (or to the middleware's
lazy-probe gate) can't silently regress to short-circuiting on the
first binding only.

Also tightens the merge-loop's pending-slot drop path to use
`Option::map` (clippy::manual_map) — cosmetic, no behavior change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: low Changes to docs, tests, or low-risk modules size: L 200-499 changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant