MoonshotAI / MoonshotAI/kimi-cli
fix(hooks): PostToolUse / PostToolUseFailure tasks collected by GC before completion
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 11.4k
- Forks
- 1.3k
- Avg merge
- 9h 47m
- Merged PRs (30d)
- 2
Description
Describe the bug
PostToolUse and PostToolUseFailure hooks registered in config.toml are silently dropped — the subprocess either never starts or is killed mid-execution. The hooks fire non-deterministically: sometimes they run, sometimes they don't.
Root cause
In kimi_cli/soul/toolset.py (lines 576–591 in v1.49.0), both hooks use bare asyncio.create_task() with the result stored in a local variable _hook_task:
# --- PostToolUse (fire-and-forget) ---
_hook_task = asyncio.create_task(
self._hook_engine.trigger(
"PostToolUse",
matcher_value=tool_name,
input_data=events.post_tool_use(...),
)
)
_hook_task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None)
return ToolResult(tool_call_id=tool_call.id, return_value=ret) # ← _hook_task goes out of scope
The return on the next line causes _hook_task to go out of scope. Since asyncio holds tasks in a WeakSet, Python's GC can collect the still-pending task before the hook subprocess completes.
The fix already exists in the codebase
HookEngine.fire_and_forget_trigger() (in kimi_cli/hooks/engine.py:93) was created specifically for this pattern — its docstring literally describes this exact bug:
def fire_and_forget_trigger(self, ...):
"""Trigger a hook in the background and keep a strong reference to the
task. asyncio holds tasks in a WeakSet, so naively writing
``asyncio.create_task(engine.trigger(...))`` and discarding the local
variable lets Python's GC collect the still-pending task ...
Use this helper any time the caller wants to fire a hook without
awaiting its completion.
"""
SubagentStop in kimi_cli/subagents/runner.py:301 already uses fire_and_forget_trigger() correctly. PostToolUse and PostToolUseFailure were not migrated.
Suggested fix
Replace asyncio.create_task() + add_done_callback() with self._hook_engine.fire_and_forget_trigger() in two places in toolset.py:
# --- PostToolUseFailure (fire-and-forget) ---
-_hook_task = asyncio.create_task(
- self._hook_engine.trigger(
- "PostToolUseFailure",
- ...
- )
-)
-_hook_task.add_done_callback(
- lambda t: t.exception() if not t.cancelled() else None
-)
+self._hook_engine.fire_and_forget_trigger(
+ "PostToolUseFailure",
+ ...
+)
# --- PostToolUse (fire-and-forget) ---
-_hook_task = asyncio.create_task(
- self._hook_engine.trigger(
- "PostToolUse",
- ...
- )
-)
-_hook_task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None)
+self._hook_engine.fire_and_forget_trigger(
+ "PostToolUse",
+ ...
+)
To reproduce
- Add a PostToolUse hook to
config.toml:
[[hooks]]
event = "PostToolUse"
command = "python3 /path/to/my-hook.py"
matcher = "^kanban_move$"
timeout = 30
-
Run a session that triggers
kanban_movemultiple times in rapid succession (batch operations). -
Observe that some hook invocations never produce output / never start the subprocess.
Affected versions
- v1.48.0
- v1.49.0 (latest)
Environment
- macOS 15 (Apple Silicon)
- Python 3.14
- kimi-cli 1.48.0 / 1.49.0
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in kimi_cli/soul/toolset.py at the PostToolUse and PostToolUseFailure hook calls, then compare them with HookEngine.fire_and_forget_trigger() in kimi_cli/hooks/engine.py and its existing use in kimi_cli/subagents/runner.py. The work is complete when both affected hook paths use the established task-lifetime handling and rapid invocations no longer disappear before completion.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- cli
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 88/100