Skip to content

Child-outcome hook admits internal-agent stops; its matcher guard is unpinned #5275

Open

Description

@Trecek

Fix target and dependency — mandatory routing

Repository: TalonT-Org/AutoSkillit.
Fix PR base branch: develop.
Blocked by: #5271, and by the merge of its parent PR #5255 into develop.

Order of work:

  1. Unfiltered SDK stops enter E2E native-session coverage accounting #5271 is fixed first. Its remediation PR targets impl-issue-5233-20261008-implementation, the branch of PR Add seeded sandbox implementation E2E validation #5255, not develop.
  2. PR Add seeded sandbox implementation E2E validation #5255 merges into develop, carrying that fix.
  3. Only then start this ticket, on a branch cut from develop.

Why this order: the #5271 fix adds a test-side model of Claude Code's hook-matcher rule to tests/_hook_protocol_oracle.py (planned name claude_matcher_admits). This ticket's regression test must use that model and must not add a second matcher interpreter. The model reaches develop only through #5255.

Gate before starting: git grep -n "def claude_matcher_admits" origin/develop -- tests/_hook_protocol_oracle.py prints a match. If it prints nothing, this ticket is still blocked. If the function landed under another name, use whichever matcher model the #5271 fix added to that file.

Do not open this fix against impl-issue-5233-20261008-implementation. That branch is under commissioning for #5255, and this ticket changes product code that the E2E pipeline work does not need.

Priority: after the commissioning blockers #5270 through #5274. This ticket does not block #5255.

Defect

The child-outcome observer treats every SubagentStart and SubagentStop callback it receives as evidence of a child the session spawned. Claude Code also fires SubagentStop for its own internal agents. The only thing that keeps those callbacks away from the observer is the .+ matcher on its registration, and that protection is neither stated nor tested.

Checked on develop at 381afb4e5:

  • _handle_subagent_stop (src/autoskillit/hooks/lifecycle/child_outcome_hook.py:124-155) requires a non-empty session_id and agent_id and nothing else. It never reads agent_type.
  • _handle_subagent_start (:96-121) has the same two requirements. It reads agent_type only to add a role record.
  • record_terminal_evidence (src/autoskillit/hooks/_child_outcome_snapshot/_snapshot.py:456-501) creates the child entry on first evidence and sets start_confirmed = True. Its docstring says "an evidence record on its own still confirms the child exists".
  • The registrations for both events use matcher=r".+" (src/autoskillit/hook_registry/_registry_data.py:610-627).
  • test_replayed_subagent_stop_with_unpinned_reason_stays_unknown_and_preserves_raw_value in tests/hooks/test_child_outcomes.py sends a SubagentStop with no prior start and no agent_type, and asserts the child row exists. The handler's admission is therefore pinned by a test.
  • No test asserts the matcher. The literal ".+" appears nowhere under tests/.
  • The registry comment (_registry_data.py:600-609) says .+ was chosen "solely to avoid colliding, in generate_codex_hooks_config()'s matcher-string exclusion check", and calls it "functionally identical for a non-empty agent_type field". Neither generate_codex_hooks_config nor codex_emitted_hook_defs (src/autoskillit/execution/backends/_codex_hooks.py) compares matcher strings; I found no such check. What does force a non-empty matcher is HookDef.__post_init__ (src/autoskillit/hook_registry/_hooks_defs.py), because these events are not in _MATCHERLESS_EVENT_TYPES.

The comment invites replacing .+ with .* as equivalent. .* satisfies HookDef, matches an empty string, and no test would fail.

What Claude Code does

Hooks reference, https://code.claude.com/docs/en/hooks.md, section "SubagentStop", retrieved 2026-10-13 (link markup removed):

Not every SubagentStop event comes from a subagent Claude spawned. Claude Code also runs internal agents for some of its own features, such as prompt suggestions and /btw side questions, and SubagentStop fires when one of those finishes too. For those events, agent_type is the agent name the session itself runs as, such as one set with --agent or the agent setting, and an empty string when the session runs without one.

A matcher that names agent types doesn't match an empty agent_type. A hook whose matcher is omitted, "", or "*", or is a regular expression that matches an empty string, runs for events with an empty agent_type too.

Claude Code's changelog for 2.1.275: "Fixed SubagentStop hooks with a specific matcher firing for every stopping subagent whose agent type was empty". The product's minimum Claude Code version is 2.1.280 (src/autoskillit/core/types/execution/_type_backend.py:471).

How an internal-agent callback reaches the observer

  1. The matcher changes. Any pattern that matches an empty string delivers internal stops to the observer in every session. Nothing fails when that change is made.
  2. The session runs with a main agent. Internal stops then carry that agent's name, and .+ matches it. A main agent can come from --agent, from an agent setting, or from a plugin's default settings (plugin components reference, https://code.claude.com/docs/en/plugins/components.md, "Default settings"). This path follows from the documentation and the code above. It has not been observed: no retained session runs with a main agent.

In both cases the stop has no preceding SubagentStart, and the observer records a child with start_confirmed = True and an unknown terminal reason.

Evidence that the matcher is what protects today, from the #5271 investigation: in E2E run 38431354139 (Claude Code 2.1.280, no main agent) the root session received one SubagentStop with agent_type: "". Telemetry showed one hook executed for it where real agents had two, and the root's child-outcome store ended with children: {}. The matcherless E2E hook ran; the plugin's .+ hook did not.

Not assessed

  • What a phantom child row does downstream. The store feeds collect_and_project_child_outcomes (src/autoskillit/execution/child_outcomes.py) and the projection under src/autoskillit/execution/evidence/. The effect was not traced.
  • Whether path 2 occurs in practice. A live session with a main agent would settle it.

Required correction

  • Pin the protection with a test: under Claude Code's documented matcher rule, the SubagentStart and SubagentStop registrations for lifecycle/child_outcome_hook.py do not admit an empty agent type. Use the matcher model from Unfiltered SDK stops enter E2E native-session coverage accounting #5271. Changing .+ to .* must fail a test.
  • Correct the registry comment so that it states the real constraint and the real effect of the matcher.
  • Decide how the observer should treat an internal-agent callback that arrives with a name (path 2), then implement and test that decision. A named internal stop cannot be told apart from a real child by agent_type alone. The existing behavior for a real child whose start was missed is pinned by the test named above; do not change it without saying so.
  • Run pre-commit run --all-files and task test-local-gate.

Source

Found during the rectify pass for #5271. No open duplicate was found; the only open match is #5271 itself. Classification: bug, recipe:remediation.

Commissioning reassessment — 2026-10-15

#5271 is implemented via #5277 and integrated into parent #5255, now published at 96a18e7. The parent also contains develop9684c4e0e and the other commissioning fixes. The matcher oracle is present on the parent feature branch and is still absent from develop at that baseline, so this ticket's existing post-#5255 ordering remains applicable.

This follow-up does not block the intended default-root commissioning path. The parent checked the E2E scoped agent-hook matcher, the user-settings main-agent preflight guard, fleet argv without --agent/--settings, and the tests that pin those constraints. The documented unchecked external custom-agent settings remain outside that scoped conclusion. Keep this ticket OPEN; no product observer fix is claimed here. Source: current hook installer, matcher oracle.

Activity

  1. added
    recipe:remediationRoute: investigate/decompose before implementation
    bugExisting behavior is broken
    on Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugExisting behavior is brokenrecipe:remediationRoute: investigate/decompose before implementation

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions