Skip to content

feat: Event-driven trigger system for agent workflows#121

Merged
2witstudios merged 6 commits intomainfrom
pu/trigger-system
Mar 9, 2026
Merged

feat: Event-driven trigger system for agent workflows#121
2witstudios merged 6 commits intomainfrom
pu/trigger-system

Conversation

@2witstudios
Copy link
Owner

@2witstudios 2witstudios commented Mar 8, 2026

Summary

  • Adds an event-driven trigger system that fires actions on agent_idle, pre_commit, and pre_push events
  • Triggers are YAML definitions in .pu/triggers/ (project) and ~/.pu/triggers/ (global) with sequence-based actions: inject text into agent PTY or gate commands that must pass
  • Git hooks auto-installed on worktree creation for pre-commit/pre-push enforcement via pu gate
  • CLI commands: pu trigger list/show/create/delete, pu gate, --no-trigger spawn flag
  • Gate evaluation engine with retry support and expected exit code matching
  • TriggerState tracking on AgentEntry (Active/Gating/Completed/Failed) with progress display

Key files

  • crates/pu-core/src/trigger_def.rs — TriggerDef types, YAML CRUD, variable substitution (23 tests)
  • crates/pu-engine/src/gate.rs — Gate evaluation engine with kill_on_drop timeout cleanup (6 tests)
  • crates/pu-engine/src/engine.rs — Idle trigger advancement in scheduler tick, CRUD handlers
  • crates/pu-engine/src/git.rs — Git hook installation with absolute pu binary path
  • crates/pu-cli/src/commands/{trigger,gate}.rs — CLI commands
  • crates/pu-cli/src/output.rs — Trigger progress display

Review fixes applied

  1. Variable substitution ({{VAR}}) now applied in gate and inject execution paths
  2. Trigger state fields only set when a trigger is actually bound (not just when no_trigger=false)
  3. Git hooks use absolute path to pu binary via current_exe() for non-PATH environments
  4. Warning logged when multiple agent_idle triggers found (first is used)
  5. no_trigger simplified from Option<bool> to bool with serde defaults
  6. Gate commands use kill_on_drop(true) to terminate child processes on timeout
  7. inject_text clones PTY fd before dropping sessions lock; returns Result<bool> so callers only advance on success
  8. update_trigger_state moved to spawn_blocking, handles errors, calls notify_status_change on success
  9. gate.rs worktree_path correctly uses cwd (not project_root) for git hook execution context

Test plan

  • 465 tests pass (93 cli + 271 core + 90 engine + 11 integration)
  • Clippy clean
  • Formatting verified by pre-commit hook
  • Manual: create a trigger YAML, spawn an agent with --no-trigger, verify gate enforcement

🤖 Generated with Claude Code

Implements triggers that fire actions when events occur: agent_idle (inject
commands into idle agents), pre_commit and pre_push (gate enforcement via
git hooks). Triggers are project-level YAML definitions in .pu/triggers/
with global fallback to ~/.pu/triggers/.

Key components:
- TriggerDef YAML CRUD following schedule_def.rs pattern
- TriggerState tracking on AgentEntry (Active/Gating/Completed/Failed)
- Git hook auto-installation on worktree creation
- Gate evaluation engine with retry support
- agent_idle sequence advancement in scheduler tick
- CLI: pu trigger list/show/create/delete, pu gate, --no-trigger flag
- Status display with trigger progress indicators

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@chatgpt-codex-connector
Copy link

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai
Copy link

coderabbitai bot commented Mar 8, 2026

📝 Walkthrough

Walkthrough

Adds a trigger system: CLI commands for trigger CRUD and gate evaluation; core trigger definitions with YAML storage; engine support for trigger lifecycle, gate execution and retries; git hook installation for pre-commit/pre-push gate checks; and output/response handling for triggers and gate results.

Changes

Cohort / File(s) Summary
CLI: commands & dispatch
crates/pu-cli/src/commands/trigger.rs, crates/pu-cli/src/commands/gate.rs, crates/pu-cli/src/commands/mod.rs, crates/pu-cli/src/main.rs, crates/pu-cli/src/commands/spawn.rs
Add trigger and gate CLI modules and subcommands (list/show/create/delete, evaluate gate). Thread new no_trigger: bool flag through spawn command and dispatch trigger/gate actions to daemon.
Output / Responses
crates/pu-cli/src/output.rs, crates/pu-core/src/protocol.rs
Add Response variants for TriggerList/TriggerDetail/GateResult and augment print_response to render trigger progress, trigger lists/details, and gate outputs. Protocol updated to carry trigger/gate payloads and Spawn.no_trigger.
Core trigger model & paths
crates/pu-core/src/trigger_def.rs, crates/pu-core/src/types.rs, crates/pu-core/src/paths.rs, crates/pu-core/src/lib.rs
New trigger_def module: TriggerDef, TriggerAction, GateDef, TriggerEvent, TriggerState; YAML (de)serialization, variable substitution, list/find/save/delete helpers; path helpers for local/global triggers.
Engine: runtime, gate evaluation, hooks
crates/pu-engine/src/engine.rs, crates/pu-engine/src/gate.rs, crates/pu-engine/src/git.rs, crates/pu-engine/src/lib.rs, crates/pu-engine/src/test_helpers.rs
Engine request handlers for trigger CRUD and EvaluateGate; spawn logic binds triggers (unless no_trigger), updates agent trigger state, injects text, evaluates gates with retries and expected exit codes; git hook installation for worktrees; run_gate_command + evaluate_trigger_gates implemented.
Tests & helpers
crates/pu-engine/.../test_helpers.rs, crates/pu-core/... (tests inside modules)
Add/extend tests for serialization, trigger CRUD, gate evaluation, hook installation, and Spawn.no_trigger wiring across helpers and engine tests.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant CLI as CLI
    participant Daemon as Engine/Daemon
    participant Store as TriggerDefs
    participant GateExec as Gate Executor
    participant Agent as Agent/Worktree

    User->>CLI: spawn (no_trigger: false) / trigger create / gate evaluate
    CLI->>Daemon: Request::Spawn or Trigger CRUD or EvaluateGate
    activate Daemon
    Daemon->>Store: list/find/load trigger defs
    Store-->>Daemon: TriggerDef(s)
    Daemon->>Agent: spawn agent / install hooks / bind trigger (set trigger_name)
    Daemon->>GateExec: evaluate_trigger_gates(worktree_path, TriggerDef)
    activate GateExec
    GateExec->>Agent: run_gate_command (exec shell in worktree)
    Agent-->>GateExec: exit code + stdout/stderr
    GateExec-->>Daemon: GateEvalResult (passed/failed + output)
    Daemon->>Daemon: update trigger_state / seq_index / inject messages
    Daemon-->>CLI: Response::TriggerList / TriggerDetail / GateResult / Spawned
    deactivate GateExec
    deactivate Daemon
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰
I hopped along a YAML trail,
gates that pass or sometimes fail,
hooks that tap at pre-push time,
triggers humming in a rhyme —
carrots, code, and signals hale! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 65.05% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main feature added: an event-driven trigger system for agent workflows. It aligns with the primary changes across the codebase.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch pu/trigger-system

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.

- Fix event name mismatch: normalize hyphenated form (pre-commit) to
  underscored (pre_commit) in handle_evaluate_gate so git hooks work
- Don't overwrite existing git hooks (preserves husky/lefthook/custom)
- Escape single quotes in project root path for shell safety in hooks
- Bind each agent to a specific trigger by name (trigger_name field on
  AgentEntry) so sequence is stable across scheduler ticks
- Add 60s timeout on gate commands to prevent scheduler blocking
- Replace manual TriggerDef↔TriggerInfo conversion with From impls
- Remove scan_dir fallback in find_in_dir (surface parse errors)
- Replace #[allow(clippy::too_many_arguments)] with CreateTriggerParams

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

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

⚠️ Outside diff range comments (1)
crates/pu-engine/src/engine.rs (1)

952-1000: ⚠️ Potential issue | 🟡 Minor

Only initialize trigger progress when a usable idle trigger is actually bound.

If no idle trigger exists — or the first definition has an empty sequence — this still writes trigger_seq_index = Some(0), trigger_state = Some(Active), and gate_attempts = Some(0) while trigger_name stays None. The agent will then show an active trigger forever even though evaluate_idle_triggers() skips it.

Suggested fix
-        let (trigger_name, trigger_total) = if no_trigger {
+        let (trigger_name, trigger_total) = if no_trigger {
             (None, None)
         } else {
             let pr = project_root.to_string();
             let found = tokio::task::spawn_blocking(move || {
-                let triggers = pu_core::trigger_def::triggers_for_event(
+                pu_core::trigger_def::triggers_for_event(
                     Path::new(&pr),
                     &pu_core::trigger_def::TriggerEvent::AgentIdle,
-                );
-                triggers.into_iter().next().map(|t| {
-                    let len = t.sequence.len() as u32;
-                    (t.name, len)
-                })
+                )
+                .into_iter()
+                .find(|t| !t.sequence.is_empty())
+                .map(|t| (t.name, t.sequence.len() as u32))
             })
             .await
             .unwrap_or(None);
             match found {
                 Some((name, total)) if total > 0 => (Some(name), Some(total)),
                 _ => (None, None),
             }
         };
+        let trigger_enabled = !no_trigger && trigger_name.is_some();
 ...
-            trigger_seq_index: if no_trigger { None } else { Some(0) },
-            trigger_state: if no_trigger {
-                None
-            } else {
-                Some(pu_core::types::TriggerState::Active)
-            },
+            trigger_seq_index: trigger_enabled.then_some(0),
+            trigger_state: trigger_enabled.then_some(pu_core::types::TriggerState::Active),
             trigger_total,
-            gate_attempts: if no_trigger { None } else { Some(0) },
+            gate_attempts: trigger_enabled.then_some(0),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/pu-engine/src/engine.rs` around lines 952 - 1000, The manifest
initializes trigger progress unconditionally, causing agents to appear active
when no usable trigger is bound; modify the AgentEntry construction in this
block so trigger_seq_index, trigger_state, and gate_attempts are only Some(...)
when a usable trigger is actually bound (i.e., trigger_name.is_some() and
trigger_total.unwrap_or(0) > 0); otherwise leave them None (and keep
trigger_name None). Locate the AgentEntry initialization near the binding logic
(referenced by variables trigger_name, trigger_total, no_trigger and function
evaluate_idle_triggers) and gate the fields on that bound condition.
🧹 Nitpick comments (6)
crates/pu-engine/src/git.rs (1)

47-49: Note: This concern applies only if the function is ever called on the main repository.

git rev-parse --git-dir returns .git (relative) for the main repository but an absolute path for worktrees. For worktree calls (the current and intended use), the returned path is absolute and the file operations work correctly. However, if install_hooks were called on a main repository, the relative path would resolve relative to the process's current working directory rather than the repository location, potentially creating hooks in the wrong location.

Given the function is designed for and only called with worktrees (where absolute paths are returned), this is not a current issue, but could be a concern if the function's usage expands in the future.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/pu-engine/src/git.rs` around lines 47 - 49, install_hooks uses run_git
to call "rev-parse --git-dir" and then treats the result as an absolute path,
but for a main repo git returns a relative ".git" which would be resolved
against the process CWD; ensure the git dir is made absolute by checking if
git_dir (from run_git) is a relative path and, if so, joining it with the
worktree_path before creating hooks_dir — update the logic around
git_dir/hooks_dir in install_hooks (and any helper that calls run_git) so
tokio::fs::create_dir_all works on the correct absolute path.
crates/pu-engine/src/gate.rs (1)

76-102: Consider handling signal-terminated processes more explicitly.

On line 98, output.status.code().unwrap_or(-1) returns -1 when the process was terminated by a signal (where code() returns None). This is reasonable, but users may not understand why a gate failed with exit code -1.

Consider adding signal information to the output when the process was killed by a signal, though this is a minor enhancement for better debugging.

♻️ Optional: Add signal info for terminated processes
     let exit_code = output.status.code().unwrap_or(-1);
+    #[cfg(unix)]
+    if exit_code == -1 {
+        use std::os::unix::process::ExitStatusExt;
+        if let Some(sig) = output.status.signal() {
+            // Process was killed by signal; exit_code remains -1
+            // The stderr will typically contain relevant info
+        }
+    }
     let stdout = String::from_utf8_lossy(&output.stdout).to_string();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/pu-engine/src/gate.rs` around lines 76 - 102, run_gate_command
currently maps a signal-terminated process to exit code -1 via
output.status.code().unwrap_or(-1), which is confusing; update run_gate_command
to detect when ExitStatus has no code and append signal information instead:
check output.status.code(), and if None on Unix use
std::os::unix::process::ExitStatusExt::signal() (or equivalent) to obtain the
signal number/name and include that detail in the returned stderr/stdout or in a
small human-readable message (e.g., "killed by signal SIGKILL (9)"); keep the
existing timeout/GATE_TIMEOUT behavior and only change how signal-terminated
cases are represented so callers of run_gate_command can see the signal info
instead of just -1.
crates/pu-cli/src/output.rs (1)

146-152: Minor: Worktree agents don't display trigger progress.

Lines 146-152 show agents within worktrees but don't include the trigger_progress(a) call, unlike the top-level agents listing (lines 123-128). This could be intentional if worktree agents don't use triggers, but it creates inconsistency.

♻️ Optional: Add trigger progress to worktree agent display
                     for a in wt.agents.values() {
                         println!(
-                            "  {:<14} {:<16} {}",
+                            "  {:<14} {:<16} {}{}",
                             a.id.dimmed(),
                             a.name,
                             status_colored(a.status, a.exit_code),
+                            trigger_progress(a)
                         );
                     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/pu-cli/src/output.rs` around lines 146 - 152, The worktree agent
listing loop (iterating wt.agents.values()) prints id, name and
status_colored(a.status, a.exit_code) but omits trigger_progress(a), causing
inconsistency with the top-level agents display; update the println in that
block to include trigger_progress(a) (same call used for top-level agents) as an
additional formatted column and adjust the format string/spacing to match the
other agents listing so worktree agents show trigger progress too.
crates/pu-core/src/trigger_def.rs (2)

135-159: Parse errors are logged to stderr but silently skipped.

In scan_dir (lines 150-152), malformed YAML files produce a warning to stderr but are otherwise ignored. This is reasonable behavior for listing, but users might not notice their trigger files have syntax errors until they try to use them.

Consider whether this is the desired UX or if a more prominent warning mechanism would be beneficial.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/pu-core/src/trigger_def.rs` around lines 135 - 159, The current
scan_dir function swallows parse failures by printing to stderr with eprintln!
and continuing; change it to surface parse errors instead by either (A)
replacing eprintln! with the project's structured logger (e.g., log::warn! or
tracing::warn!) so warnings go to the app's logging system and include the file
path and serde_yml error, or (B) change scan_dir's signature to return a
Result<Vec<TriggerDef>, Vec<(PathBuf, serde_yml::Error)>> (or similar) so
callers of scan_dir can decide how to report/handle malformed YAML; update uses
of scan_dir accordingly and keep the existing behavior for successful TriggerDef
parsing. Ensure references to scan_dir, TriggerDef, serde_yml, and the current
eprintln! call are replaced/handled.

47-54: Variable substitution doesn't handle edge cases.

The substitute_variables function performs simple string replacement, which works for the common case. However, consider these edge cases:

  1. Variable values containing {{ could lead to unexpected behavior if substituted values themselves look like placeholders
  2. No escape mechanism if users want literal {{VAR}} in output

These are likely acceptable tradeoffs for simplicity, but worth documenting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/pu-core/src/trigger_def.rs` around lines 47 - 54, The
substitute_variables function currently does naive repeated replace which can
re-introduce placeholders or mis-handle values containing "{{"; change
substitute_variables to perform a single-pass, non-recursive substitution by
scanning the input once for {{KEY}} tokens (e.g., using a regex or manual
scanner) and replacing each match with the corresponding
variables.get(KEY).unwrap_or_default() without re-scanning the inserted value,
and add support for a simple escape (treat "\{{KEY}}" as a literal "{{KEY}}" in
output). Update the doc comment on substitute_variables to explicitly state that
substitutions are single-pass (no recursive expansion), values are inserted
verbatim (braces in values are not re-parsed), and the "\{{...}}" escape yields
a literal placeholder.
crates/pu-cli/src/commands/trigger.rs (1)

45-91: Flag order is not preserved when collecting inject and gate actions.

The implementation processes all --inject flags first (lines 51-57), then all --gate flags (lines 58-67). This causes:

pu trigger create foo --on pre_commit --inject "/test" --gate "cargo test" --inject "/commit"

to produce sequence: [inject:/test, inject:/commit, gate:cargo test] instead of [inject:/test, gate:cargo test, inject:/commit].

This happens because clap parses --inject and --gate into separate vectors. To preserve interleaved order, consider collecting actions into a single vector during parsing or refactoring the clap structure. Alternatively, if the current behavior is intentional, document it in the CLI help text.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/pu-cli/src/commands/trigger.rs` around lines 45 - 91, The current
run_create function builds the trigger sequence by appending all params.injects
then all params.gates, losing the original flag order; update run_create (or the
CLI parsing for CreateTriggerParams) so actions are collected in flag
order—either change the clap parsing to return a single ordered list of actions
(inject/gate enum) or, in run_create, reconstruct order from the raw
argv/occurrence positions before creating TriggerActionPayload entries for
sequence; ensure you update the code paths that push TriggerActionPayload (the
inject and GatePayload construction) so they insert actions in the original
interleaved order rather than batching injects then gates.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/pu-cli/src/commands/gate.rs`:
- Around line 16-17: The code sets worktree_path = project_root.clone() but per
the comment it should be the current working directory where the git hook runs;
change the initialization of worktree_path in gate.rs to use the repository's
current working directory (e.g. std::env::current_dir()) instead of cloning
project_root so trigger detection (project_root) and command execution
(worktree_path) are correctly separated; update any downstream uses of
worktree_path to expect a PathBuf from current_dir.

In `@crates/pu-engine/src/engine.rs`:
- Around line 3513-3534: The update_trigger_state function performs synchronous
manifest::update_manifest disk I/O directly on the async scheduler, ignores the
Result, and never calls notify_status_change(); fix by moving the manifest write
into a blocking task (e.g. tokio::task::spawn_blocking or equivalent) and await
its Result, handle/log errors instead of discarding them, and after a successful
manifest update call the engine's notify_status_change() to notify
SubscribeStatus clients; update references to agent.trigger_state,
trigger_seq_index and gate_attempts inside the closure passed to
manifest::update_manifest as before, but ensure the blocking task returns the
write outcome so notify_status_change() is only invoked on success.
- Around line 3505-3510: In inject_text: don't hold the sessions lock across the
await—clone or copy the PTY fd (use handle.master_fd() value) while the sessions
guard is held, then drop the guard before calling pty_host.write_to_fd; also
change inject_text to surface the write result (e.g., return Result<bool, Error>
or bool) so callers can only advance sequences after receiving Ok(true) from the
write, and ensure failures are propagated instead of returning () so
evaluate_idle_triggers won't advance on a disappeared/failed session.

---

Outside diff comments:
In `@crates/pu-engine/src/engine.rs`:
- Around line 952-1000: The manifest initializes trigger progress
unconditionally, causing agents to appear active when no usable trigger is
bound; modify the AgentEntry construction in this block so trigger_seq_index,
trigger_state, and gate_attempts are only Some(...) when a usable trigger is
actually bound (i.e., trigger_name.is_some() and trigger_total.unwrap_or(0) >
0); otherwise leave them None (and keep trigger_name None). Locate the
AgentEntry initialization near the binding logic (referenced by variables
trigger_name, trigger_total, no_trigger and function evaluate_idle_triggers) and
gate the fields on that bound condition.

---

Nitpick comments:
In `@crates/pu-cli/src/commands/trigger.rs`:
- Around line 45-91: The current run_create function builds the trigger sequence
by appending all params.injects then all params.gates, losing the original flag
order; update run_create (or the CLI parsing for CreateTriggerParams) so actions
are collected in flag order—either change the clap parsing to return a single
ordered list of actions (inject/gate enum) or, in run_create, reconstruct order
from the raw argv/occurrence positions before creating TriggerActionPayload
entries for sequence; ensure you update the code paths that push
TriggerActionPayload (the inject and GatePayload construction) so they insert
actions in the original interleaved order rather than batching injects then
gates.

In `@crates/pu-cli/src/output.rs`:
- Around line 146-152: The worktree agent listing loop (iterating
wt.agents.values()) prints id, name and status_colored(a.status, a.exit_code)
but omits trigger_progress(a), causing inconsistency with the top-level agents
display; update the println in that block to include trigger_progress(a) (same
call used for top-level agents) as an additional formatted column and adjust the
format string/spacing to match the other agents listing so worktree agents show
trigger progress too.

In `@crates/pu-core/src/trigger_def.rs`:
- Around line 135-159: The current scan_dir function swallows parse failures by
printing to stderr with eprintln! and continuing; change it to surface parse
errors instead by either (A) replacing eprintln! with the project's structured
logger (e.g., log::warn! or tracing::warn!) so warnings go to the app's logging
system and include the file path and serde_yml error, or (B) change scan_dir's
signature to return a Result<Vec<TriggerDef>, Vec<(PathBuf, serde_yml::Error)>>
(or similar) so callers of scan_dir can decide how to report/handle malformed
YAML; update uses of scan_dir accordingly and keep the existing behavior for
successful TriggerDef parsing. Ensure references to scan_dir, TriggerDef,
serde_yml, and the current eprintln! call are replaced/handled.
- Around line 47-54: The substitute_variables function currently does naive
repeated replace which can re-introduce placeholders or mis-handle values
containing "{{"; change substitute_variables to perform a single-pass,
non-recursive substitution by scanning the input once for {{KEY}} tokens (e.g.,
using a regex or manual scanner) and replacing each match with the corresponding
variables.get(KEY).unwrap_or_default() without re-scanning the inserted value,
and add support for a simple escape (treat "\{{KEY}}" as a literal "{{KEY}}" in
output). Update the doc comment on substitute_variables to explicitly state that
substitutions are single-pass (no recursive expansion), values are inserted
verbatim (braces in values are not re-parsed), and the "\{{...}}" escape yields
a literal placeholder.

In `@crates/pu-engine/src/gate.rs`:
- Around line 76-102: run_gate_command currently maps a signal-terminated
process to exit code -1 via output.status.code().unwrap_or(-1), which is
confusing; update run_gate_command to detect when ExitStatus has no code and
append signal information instead: check output.status.code(), and if None on
Unix use std::os::unix::process::ExitStatusExt::signal() (or equivalent) to
obtain the signal number/name and include that detail in the returned
stderr/stdout or in a small human-readable message (e.g., "killed by signal
SIGKILL (9)"); keep the existing timeout/GATE_TIMEOUT behavior and only change
how signal-terminated cases are represented so callers of run_gate_command can
see the signal info instead of just -1.

In `@crates/pu-engine/src/git.rs`:
- Around line 47-49: install_hooks uses run_git to call "rev-parse --git-dir"
and then treats the result as an absolute path, but for a main repo git returns
a relative ".git" which would be resolved against the process CWD; ensure the
git dir is made absolute by checking if git_dir (from run_git) is a relative
path and, if so, joining it with the worktree_path before creating hooks_dir —
update the logic around git_dir/hooks_dir in install_hooks (and any helper that
calls run_git) so tokio::fs::create_dir_all works on the correct absolute path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 95507bf4-2f60-40a2-86ba-0d86f6f7141b

📥 Commits

Reviewing files that changed from the base of the PR and between ffbf81d and 2d48528.

📒 Files selected for processing (16)
  • crates/pu-cli/src/commands/gate.rs
  • crates/pu-cli/src/commands/mod.rs
  • crates/pu-cli/src/commands/spawn.rs
  • crates/pu-cli/src/commands/trigger.rs
  • crates/pu-cli/src/main.rs
  • crates/pu-cli/src/output.rs
  • crates/pu-core/src/lib.rs
  • crates/pu-core/src/paths.rs
  • crates/pu-core/src/protocol.rs
  • crates/pu-core/src/trigger_def.rs
  • crates/pu-core/src/types.rs
  • crates/pu-engine/src/engine.rs
  • crates/pu-engine/src/gate.rs
  • crates/pu-engine/src/git.rs
  • crates/pu-engine/src/lib.rs
  • crates/pu-engine/src/test_helpers.rs

- Replace handle_save_trigger args with SaveTriggerParams struct
- Normalize event names in handle_save_trigger (consistent with gate)
- Use shared DEFAULT_GATE_MAX_RETRIES constant (was 3 in idle, 0 in hooks)
- Add 5-minute total timeout on evaluate_trigger_gates for git hooks
- Wire up Gating state: set before running gate command in idle triggers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (1)
crates/pu-engine/src/engine.rs (1)

962-1010: ⚠️ Potential issue | 🟠 Major

Only initialize trigger fields when a trigger was actually bound.

If no agent_idle trigger exists, this still writes trigger_seq_index = 0, trigger_state = Active, and gate_attempts = 0 while trigger_name stays None. Those agents will look trigger-bound in status output even though nothing can ever advance.

Suggested fix
-        let (trigger_name, trigger_total) = if no_trigger {
+        let (trigger_name, trigger_total) = if no_trigger {
             (None, None)
         } else {
             let pr = project_root.to_string();
             let found = tokio::task::spawn_blocking(move || {
                 let triggers = pu_core::trigger_def::triggers_for_event(
@@
             match found {
                 Some((name, total)) if total > 0 => (Some(name), Some(total)),
                 _ => (None, None),
             }
         };
+        let trigger_bound = trigger_name.is_some();

@@
-            trigger_seq_index: if no_trigger { None } else { Some(0) },
-            trigger_state: if no_trigger {
-                None
-            } else {
+            trigger_seq_index: if trigger_bound { Some(0) } else { None },
+            trigger_state: if trigger_bound {
                 Some(pu_core::types::TriggerState::Active)
+            } else {
+                None
             },
             trigger_total,
-            gate_attempts: if no_trigger { None } else { Some(0) },
+            gate_attempts: if trigger_bound { Some(0) } else { None },
             no_trigger: if no_trigger { Some(true) } else { None },
             trigger_name: trigger_name.clone(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/pu-engine/src/engine.rs` around lines 962 - 1010, The AgentEntry is
currently populated with trigger-related defaults even when no trigger was
found; change the initialization so trigger_seq_index, trigger_state,
gate_attempts, trigger_total, and no_trigger are only set when a trigger was
actually bound (i.e., when trigger_name/trigger_total is Some). Concretely, in
the AgentEntry construction (variable agent_entry) replace the existing
conditional expressions that use the no_trigger boolean with checks against
trigger_name.is_some() or trigger_total.is_some() so that trigger_seq_index =
None, trigger_state = None, and gate_attempts = None whenever trigger_name is
None (and only populate them when a trigger exists).
♻️ Duplicate comments (2)
crates/pu-engine/src/engine.rs (2)

3529-3550: ⚠️ Potential issue | 🟠 Major

Move trigger-state manifest writes off the async path and notify listeners on success.

manifest::update_manifest() is synchronous disk I/O here, its result is discarded, and no notify_status_change() follows. Slow storage can stall the scheduler, and SubscribeStatus clients will miss trigger progress changes until some unrelated status update happens.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/pu-engine/src/engine.rs` around lines 3529 - 3550,
update_trigger_state currently performs synchronous disk I/O via
manifest::update_manifest on the async path and discards its result, causing
scheduler stalls and missed SubscribeStatus updates; change update_trigger_state
to run the manifest::update_manifest call inside a blocking context (e.g.,
tokio::task::spawn_blocking or equivalent) so it does not block the async
runtime, check/handle the Result from manifest::update_manifest (log or return
error on failure), and on successful write invoke notify_status_change() (or the
appropriate status notification function) so listeners receive trigger-state
updates; keep the existing mutation logic (agent.trigger_state,
trigger_seq_index, gate_attempts) but ensure the disk write is awaited
off-thread and only after success you call notify_status_change().

3521-3526: ⚠️ Potential issue | 🟠 Major

Don't hold self.sessions across the PTY write, and don't ignore failures.

This still awaits write_to_fd() while the session mutex is held and still returns (). If the session disappears or the write fails, callers at Line 3465 and Line 3505 can advance trigger state as if injection succeeded.

Suggested fix
-    async fn inject_text(&self, agent_id: &str, text: &str) {
-        let sessions = self.sessions.lock().await;
-        if let Some(handle) = sessions.get(agent_id) {
-            let fd = handle.master_fd();
-            let _ = self.pty_host.write_to_fd(&fd, text.as_bytes()).await;
-        }
+    async fn inject_text(&self, agent_id: &str, text: &str) -> Result<bool, std::io::Error> {
+        let fd = {
+            let sessions = self.sessions.lock().await;
+            sessions.get(agent_id).map(|handle| handle.master_fd())
+        };
+        match fd {
+            Some(fd) => {
+                self.pty_host.write_to_fd(&fd, text.as_bytes()).await?;
+                Ok(true)
+            }
+            None => Ok(false),
+        }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/pu-engine/src/engine.rs` around lines 3521 - 3526, The inject_text
function currently holds self.sessions.lock().await while awaiting
pty_host.write_to_fd and also ignores the write result; change it to only
acquire the session handle and master_fd while holding the mutex, then drop the
lock before calling self.pty_host.write_to_fd (so do not await while sessions is
locked), and propagate or return an error/result instead of swallowing failures
so callers (e.g., callers of inject_text) can react if the session vanishes or
the write fails; update inject_text's signature to return a Result (or
appropriate outcome) and ensure errors from pty_host.write_to_fd are returned
rather than ignored.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/pu-engine/src/gate.rs`:
- Around line 109-125: The spawned shell process isn't killed when
tokio::time::timeout drops the inner future, so update the Command construction
in the output assignment to enable kill-on-drop: call .kill_on_drop(true) on the
tokio::process::Command before .output() (the block that builds the
Command::new("sh").arg("-c").arg(command).current_dir(cwd).stdout(...).stderr(...)).
This ensures the child process is terminated if the GATE_TIMEOUT fires; leave
the existing timeout/error handling around GATE_TIMEOUT and the command variable
intact.

---

Outside diff comments:
In `@crates/pu-engine/src/engine.rs`:
- Around line 962-1010: The AgentEntry is currently populated with
trigger-related defaults even when no trigger was found; change the
initialization so trigger_seq_index, trigger_state, gate_attempts,
trigger_total, and no_trigger are only set when a trigger was actually bound
(i.e., when trigger_name/trigger_total is Some). Concretely, in the AgentEntry
construction (variable agent_entry) replace the existing conditional expressions
that use the no_trigger boolean with checks against trigger_name.is_some() or
trigger_total.is_some() so that trigger_seq_index = None, trigger_state = None,
and gate_attempts = None whenever trigger_name is None (and only populate them
when a trigger exists).

---

Duplicate comments:
In `@crates/pu-engine/src/engine.rs`:
- Around line 3529-3550: update_trigger_state currently performs synchronous
disk I/O via manifest::update_manifest on the async path and discards its
result, causing scheduler stalls and missed SubscribeStatus updates; change
update_trigger_state to run the manifest::update_manifest call inside a blocking
context (e.g., tokio::task::spawn_blocking or equivalent) so it does not block
the async runtime, check/handle the Result from manifest::update_manifest (log
or return error on failure), and on successful write invoke
notify_status_change() (or the appropriate status notification function) so
listeners receive trigger-state updates; keep the existing mutation logic
(agent.trigger_state, trigger_seq_index, gate_attempts) but ensure the disk
write is awaited off-thread and only after success you call
notify_status_change().
- Around line 3521-3526: The inject_text function currently holds
self.sessions.lock().await while awaiting pty_host.write_to_fd and also ignores
the write result; change it to only acquire the session handle and master_fd
while holding the mutex, then drop the lock before calling
self.pty_host.write_to_fd (so do not await while sessions is locked), and
propagate or return an error/result instead of swallowing failures so callers
(e.g., callers of inject_text) can react if the session vanishes or the write
fails; update inject_text's signature to return a Result (or appropriate
outcome) and ensure errors from pty_host.write_to_fd are returned rather than
ignored.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2685e37e-c31a-405c-a5bb-62e8ff63eef3

📥 Commits

Reviewing files that changed from the base of the PR and between 2d48528 and 70cbf59.

📒 Files selected for processing (2)
  • crates/pu-engine/src/engine.rs
  • crates/pu-engine/src/gate.rs

2witstudios and others added 3 commits March 8, 2026 19:39
1. Variable substitution now applied in gate and inject paths
2. Trigger state only set when a trigger is actually bound
3. Git hooks use absolute path to pu binary via current_exe()
4. Warn when multiple agent_idle triggers found (only first used)
5. Simplify no_trigger from Option<bool> to bool
6. Gate commands use kill_on_drop(true) for timeout cleanup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Merged origin/main which added watch command, pulse endpoint, and
suspended-agent display. Resolved conflicts in mod.rs, main.rs,
output.rs, paths.rs, and engine.rs by keeping both sides' additions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- gate.rs: Use cwd (not project_root) as worktree_path so gate
  commands execute in the correct worktree directory
- inject_text: Clone PTY fd under lock then drop lock before write;
  return Result<bool> so callers can fail the trigger on write errors
- update_trigger_state: Move manifest write to spawn_blocking, handle
  errors with tracing::warn, and call notify_status_change on success
- kill_on_drop was already addressed in prior commit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@2witstudios 2witstudios merged commit ecb0b7e into main Mar 9, 2026
5 checks passed
@coderabbitai coderabbitai bot mentioned this pull request Mar 9, 2026
5 tasks
@2witstudios 2witstudios deleted the pu/trigger-system branch March 9, 2026 02:48
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.

1 participant