Repository navigation
Child-outcome hook admits internal-agent stops; its matcher guard is unpinned #5275
Description
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:
- 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, notdevelop. - PR Add seeded sandbox implementation E2E validation #5255 merges into
develop, carrying that fix. - 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-emptysession_idandagent_idand nothing else. It never readsagent_type._handle_subagent_start(:96-121) has the same two requirements. It readsagent_typeonly 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 setsstart_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_valueintests/hooks/test_child_outcomes.pysends aSubagentStopwith no prior start and noagent_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 undertests/. - 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". Neithergenerate_codex_hooks_confignorcodex_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 isHookDef.__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
/btwside questions, and SubagentStop fires when one of those finishes too. For those events,agent_typeis the agent name the session itself runs as, such as one set with--agentor theagentsetting, and an empty string when the session runs without one.
A
matcherthat names agent types doesn't match an emptyagent_type. A hook whose matcher is omitted,"", or"*", or is a regular expression that matches an empty string, runs for events with an emptyagent_typetoo.
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
- 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.
- 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 anagentsetting, 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 undersrc/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
SubagentStartandSubagentStopregistrations forlifecycle/child_outcome_hook.pydo 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_typealone. 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-filesandtask 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