feat: lifecycle hooks - #1205
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughLifecycle hooks now support four session transitions, policy approval, external-script staging, daemon execution, persistence, CLI inspection, and E2E validation. Activation supports ChangesLifecycle hooks
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant CLI
participant SessionClient
participant Minimald
participant SessionHost
CLI->>SessionClient: activate session and stage hook scripts
SessionClient->>Minimald: upload scripts and finalize session
Minimald->>SessionHost: execute lifecycle hooks
SessionHost-->>Minimald: return hook outcomes
Minimald-->>CLI: report activation or teardown result
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 19
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
crates/minimald/src/session.rs (1)
2142-2157: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winRestore the doc comment on
patches_upload_lock.The new
composition()doc lines were inserted at the end of the existing doc block forpatches_upload_lock. Lines 2142-2147 now documentcomposition(), andpatches_upload_lockat Line 2158 has no doc comment at all. The rendered docs describe the wrong function.📝 Proposed fix
- /// Handle to the per-session patches-upload lock, owned by the session - /// actor. - /// - /// Workspace patches are accumulated in a fixed per-session directory, - /// so this lock is used to serialize `WorkspacePatchesTarZst` RPCs so - /// they dont race and stomp each other. /// The session's composition, if it has one. See /// [`SessionMessage::GetComposition`]. pub async fn composition(&self) -> Result<Option<Arc<Composition>>, std::io::Error> { let (send, recv) = oneshot::channel(); let _ = self.0.send(SessionMessage::GetComposition(send)).await; recv.await.map_err(|_| { std::io::Error::new(std::io::ErrorKind::NotConnected, "session actor is gone") }) } + /// Handle to the per-session patches-upload lock, owned by the session + /// actor. + /// + /// Workspace patches are accumulated in a fixed per-session directory, + /// so this lock is used to serialize `WorkspacePatchesTarZst` RPCs so + /// they dont race and stomp each other. pub async fn patches_upload_lock(&self) -> Result<Arc<Mutex<()>>, std::io::Error> {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/minimald/src/session.rs` around lines 2142 - 2157, Restore the `patches_upload_lock` documentation immediately above that method, and move the `composition()` documentation so it directly precedes `composition()`. Ensure each doc comment describes its corresponding method and no longer leaves `patches_upload_lock` undocumented.crates/minimald/src/sessions.rs (1)
527-573: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy liftDestroy now holds the manager mainloop for up to three minutes.
ManagerMessage::DeleteSessionis served inline onManager::handle_message, so the manager actor processes nothing else untilhnd.destroy()returns. With hooks, that call can now:
- launch a sandbox, bounded by
HOOK_LAUNCH_TIMEOUT(60s,session.rsLine 440), and- run the destroy hooks, bounded by
TEARDOWN_HOOK_BUDGET(120s,session.rsLine 429).A single slow or wedged destroy therefore stalls
List,GetRecord,GetSession,GetScreen, andCreateSessionfor every other session for up to three minutes. Before this change the same handler only awaited a host kill.Consider serving the teardown off the mainloop, in the same style as
GetWorkspaceDeltainsession.rsLines 856-871: resolve the actor on the mainloop, then move thedestroy()and the record cleanup onto a spawned task that owns the responder.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/minimald/src/sessions.rs` around lines 527 - 573, Update the ManagerMessage::DeleteSession handling to resolve or start the session actor on the manager mainloop, then spawn a task that owns the responder and performs hnd.destroy() plus record cleanup asynchronously. Preserve the existing fallback deletion and error handling, following the GetWorkspaceDelta pattern so slow teardown does not block handling other manager messages.
🧹 Nitpick comments (8)
scripts/session-e2e.sh (1)
864-866: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winTwo duplicated guard expressions couple these blocks implicitly.
hook_log_hasis defined inside the block that starts at Line 467, under the condition[ -n "$SEED_DIR" ] || [ -n "$SEEDED_MFILE" ]. The restart assertion at Line 935 calls that function, but it is guarded by[ -n "$HOOK_RESTART_SID" ]instead. The call is safe today only because Line 865 repeats the same seed condition. If a later edit changes one guard,hook_log_hasbecomes an unbound command inside the restart block.Set one flag when the hook proofs run, and gate all three sites on it.
♻️ Proposed refactor
+HOOKS_PROOFS="" if [ -n "$SEED_DIR" ] || [ -n "$SEEDED_MFILE" ]; then + HOOKS_PROOFS=1HOOK_RESTART_SID="" -if [ -n "$SEED_DIR" ] || [ -n "$SEEDED_MFILE" ]; then +if [ -n "$HOOKS_PROOFS" ]; then HOOK_SEED_DIR="$(mktemp -d /tmp/mnlp2.XXXXXX)"Also applies to: 923-925
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@scripts/session-e2e.sh` around lines 864 - 866, Introduce a shared flag indicating that hook proofs are initialized, set it when the seed condition enables the hook-proof block containing hook_log_has, and replace the duplicated seed-condition checks plus the HOOK_RESTART_SID-only assertion guard with this flag at all three sites. Ensure hook_log_has is called only when the flag is set, including the restart assertion path.crates/sessions/src/wire/policy.rs (1)
93-119: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winAdd a round-trip test for
WireHookVerdict.
WireVarVerdictandWirePatchVerdicteach have a round-trip test in this module.WireHookVerdictis a new cross-process contract and has none. The existinground_triphelper makes the test small.♻️ Proposed test
#[test] fn hook_verdict_round_trips_all_variants() { let cases = [ WireHookVerdict::Approved { id: PendingId::new(1), }, WireHookVerdict::Denied { id: PendingId::new(2), }, WireHookVerdict::Ignored { id: PendingId::new(3), }, ]; for v in cases { assert_eq!(round_trip(&v), v); } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/sessions/src/wire/policy.rs` around lines 93 - 119, Add a test alongside the existing verdict round-trip tests that constructs Approved, Denied, and Ignored WireHookVerdict values with distinct PendingId values, then passes each through the existing round_trip helper and asserts equality with the original.crates/sessions/src/core/policy.rs (1)
528-724: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd unit tests for
HooksPolicyandExpandedHooksPolicy.
VarsPolicyandPatchesPolicyeach have a dedicated test block in this file.HooksPolicyhas none. This gate decides arbitrary code execution, so the precedence rules deserve the same pinning.Cover at least these cases:
denywins overignorefor the same project root.allowmatches a project root and returnsDecided(Allowed).- An unmatched project root returns
NeedsApproval.Source::UserLoadoutreturnsAllowedandSource::PackagereturnsDenied, independent of the pattern lists.- A
[hooks]section round-trips throughUserPolicyTOML, and an emptyHooksPolicyis omitted on serialize.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/sessions/src/core/policy.rs` around lines 528 - 724, Add a dedicated unit-test block covering HooksPolicy::expand_with and ExpandedHooksPolicy::check: verify deny takes precedence over ignore, allow yields Decided(Allowed), unmatched project roots yield NeedsApproval, and UserLoadout/Package sources remain Allowed/Denied regardless of patterns. Also test UserPolicy TOML round-tripping for a [hooks] section and confirm an empty HooksPolicy is omitted during serialization, following the existing VarsPolicy and PatchesPolicy test patterns.crates/sessions/src/daemon/composer.rs (1)
293-305: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winInclude the denied hook description in
ComposeError::Denied::what.Use
ProvenancedHook::hook()andLifecycleHook::description(), with"lifecycle hook"as the fallback when no description exists.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/sessions/src/daemon/composer.rs` around lines 293 - 305, Update the V::Denied handling to build ComposeError::Denied::what from the pending ProvenancedHook’s hook().description(), falling back to "lifecycle hook" when no description exists; preserve the existing source lookup and InvalidWireItem behavior for unknown IDs.crates/minimal/src/task.rs (1)
310-314: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winConsider a
--no-hooksopt-out formin task run.
min task runnow always enables lifecycle hooks. Activation hooks block activation on failure. A project with a broken or slowon_activatehook therefore blocks every task run, and the operator has no flag to bypass it.min session activateoffers--no-hooksfor exactly this case. Add the same flag toTaskRunArgsand pass!args.no_hookstohooks_enabled,stage_loadout_hook_scripts, andcompose_user_contribution.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/minimal/src/task.rs` around lines 310 - 314, Add a `no_hooks` opt-out field to `TaskRunArgs`, expose the corresponding `--no-hooks` CLI flag, and propagate its inverse (`!args.no_hooks`) to `hooks_enabled`, `stage_loadout_hook_scripts`, and `compose_user_contribution` in the task-run flow. Preserve hooks as enabled by default.crates/minimal/src/loadouts.rs (1)
244-248: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExtract the loadouts-directory path into one helper.
resolve_active_loadoutsandstage_loadout_hook_scriptsboth buildresolve_minimal_config_dir(global).join("loadouts"). The two must agree, because a loadout's script anchor is<loadouts_dir>/<name>/next to its<name>.toml. If one call site changes, staging resolves against the wrong anchor and every external script fails withMissingAnchor. Add a singleloadouts_dir(global)helper and call it from both.♻️ Proposed helper
/// The directory holding `<name>.toml` loadout files and their /// `<name>/` script directories. fn loadouts_dir(global: &GlobalArgs) -> std::path::PathBuf { resolve_minimal_config_dir(global).join("loadouts") }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/minimal/src/loadouts.rs` around lines 244 - 248, Extract the repeated loadouts path construction into a loadouts_dir helper returning resolve_minimal_config_dir(global).join("loadouts"). Update both resolve_active_loadouts and stage_loadout_hook_scripts to use this helper, preserving the existing UTF-8 validation and ensuring both loadout files and script anchors share the same directory.crates/minimald/src/rpc.rs (1)
1340-1368: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winHandle unsupported
RENAME_EXCHANGEfilesystemsWhen
renameat2(..., RENAME_EXCHANGE)returnsEINVAL,EOPNOTSUPP, orENOSYS, use a documented non-atomic fallback (remove_dir_all(dir)followed by plainrename). Native deployments can place--minimal-state-diron arbitrary filesystems, and the shared helper now covers hook uploads. Without this fallback, the missingHOOKS_READY_MARKERcan blockFinalizeSessionfor compositions that require staged hooks. Preserve the atomic path when supported.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/minimald/src/rpc.rs` around lines 1340 - 1368, Handle unsupported RENAME_EXCHANGE errors in the swap_result match by treating EINVAL, EOPNOTSUPP, and ENOSYS as fallback cases. Document the non-atomic fallback, remove the existing dir with remove_dir_all, then plain-rename staging_dir into dir; preserve the current atomic exchange and ENOENT paths unchanged.crates/minimald/src/session.rs (1)
1346-1375: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winTear the activation host down when an activation hook fails.
The code launches a host at Line 1347 and keeps it in
self.inner. On a hook failure the function returnsErrat Line 1360, so the record staysMaterializing. The session is then unattachable, becauseattachandcontextboth gate onActive. The sandbox stays up until someone destroys the session.Stop the host on the failure arm so a refused activation does not hold a sandbox open.
♻️ Proposed change
if let Some(failed) = outcomes.iter().find(|o| o.failed()) { + // The session will not become `Active`, so the host + // this launch minted has no one left to serve. + self.stop_running(false).await; return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput,🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/minimald/src/session.rs` around lines 1346 - 1375, Update the activation-hook failure branch in the session transition flow after launch_host_for_hooks(LaunchPhase::Activating) and before returning the InvalidInput error to tear down the activation host through the existing host-shutdown mechanism. Ensure cleanup completes before returning Err, while preserving the current failure message and preventing the sandbox from remaining active.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/minimal-tui/src/rpc.rs`:
- Around line 256-259: Update dashboard session creation using
DashOptions.contribution so external UserLoadout hook scripts are staged and
uploaded before FinalizeSession, ensuring the daemon receives the required
.hooks_ready marker. Do not rely on hooks_enabled: false, since it only disables
project hooks; alternatively remove loadout hooks from the dashboard
contribution.
In `@crates/minimald/src/hooks.rs`:
- Around line 591-639: Move the blocking filesystem operations in read_script
and run_one off async runtime workers: replace the std::fs::read calls for
staged and workspace scripts and the std::fs::OpenOptions terminal open with
tokio::fs or tokio::task::spawn_blocking, propagating their errors while
preserving the existing fallback and timeout behavior.
- Around line 558-569: The timeout comment in the hook execution error branch
must not claim that no processes remain running: kill_on_drop only terminates
the direct interpreter child, while descendants may survive. Soften the comment
to describe only the direct child termination, or update the command
construction to create and terminate a process group (using the relevant
command-builder setup) so descendant cleanup is guaranteed; preserve the
existing on_destroy versus on_attach/on_detach lifecycle behavior.
- Around line 1274-1281: Move the doc-comment sentence “A composition with no
hooks for the event produces no outcomes” from
a_teardown_budget_is_shared_across_all_of_its_hooks to the no_hooks_is_a_no_op
test, placing it with the existing “and does nothing.” sentence. Keep the
teardown budget test documentation focused on shared hook budget behavior.
- Around line 515-541: Move the stdin write and child wait into the same timeout
in the hook execution flow, rather than completing write_all before starting
timeout. Concurrently feed body to child.stdin while wait_with_output drains
captured output, and preserve the non-capturing wait behavior; ensure the
timeout covers both operations so blocked writes terminate the hook.
In `@crates/minimald/src/session_host.rs`:
- Around line 1326-1353: Run on_attach hooks asynchronously outside the host
actor loop instead of awaiting run_hooks_for in Host::attach; update
run_hook_plan or the attach path to spawn a task that owns the snapshotted
HookPlan and preserves existing failure warnings, while keeping teardown hooks
on their current budgeted path.
- Around line 289-322: Update the lifecycle-hook logging in the hook iteration
around `comp.lifecycle_hooks()` and its `tracing::info!` call to remove the
claim that execution is deferred or has not occurred. Ensure the surrounding
comment and log message accurately reflect that hooks execute during
transitions, while preserving the existing hook metadata and ordering.
In `@crates/minimald/src/session.rs`:
- Around line 1807-1883: Update the superseded-host attach/teardown flow so the
`TeardownDueToSuperceded` path does not await `run_hooks_headless`/detach hooks
while `Host::attach` is waiting on `old_join_hnd`; either skip detach hooks for
`Superceded` or finish the old binding teardown before running them, while
preserving hook execution for other teardown events.
In `@crates/minimald/src/sessions.rs`:
- Around line 1205-1212: Repair the doc-comment boundaries around the tests
destroy_tears_down_a_running_session and
reading_a_composition_does_not_start_the_session: move the stranded “no host is
attached) tears the actor down and removes the record.” clause back into the
former test’s comment, and ensure the latter has only its own
composition-reading description. Keep each doc comment immediately attached to
its intended test.
In `@crates/minimald/src/sessions/composables.rs`:
- Around line 466-474: Normalize legacy project provenance during snapshot
loading before constructing the ProjectComposable: when Source::Project.path
contains the session workspace path, replace it with SessionRecord.project_path
so policy matching, script lookup, hook diagnostics, and
MINIMAL_HOOK_SOURCE_NAME use the declared project path. Preserve current
handling for already-normalized paths, and add a restart regression test
covering a legacy snapshot.
In `@crates/sessions/example_project/minimal.toml`:
- Around line 112-136: The hook documentation comment conflicts with the
configured commands: on_activate and on_detach use touch, write files, and no
script references MINIMAL_HOOK_EVENT. Update the comment block near the
lifecycle hook configuration to accurately describe the actual commands and
filesystem effects, or modify the commands to match the documented behavior,
while keeping the copy-source example internally consistent.
In `@crates/sessions/src/client/hookscripts.rs`:
- Around line 198-208: Update the anchor validation around anchor_meta to return
MissingAnchor only when symlink_metadata reports NotFound; propagate other
metadata I/O failures through the existing StageError::Io path, matching the
per-component walk’s error distinction. Keep successful directory validation and
non-directory handling unchanged.
In `@crates/sessions/src/core/source.rs`:
- Line 20: Update the intra-doc link in the surrounding documentation to
reference the existing check method on ExpandedPatchesPolicy rather than the
nonexistent PatchesPolicy::check symbol, preserving the intended policy-check
documentation.
In `@crates/sessions/src/wire/request.rs`:
- Around line 88-89: Update the documentation comment for the version field to
state that its default is LEGACY_COMPOSITION_SNAPSHOT_VERSION, matching
default_composition_version, and remove the outdated
COMPOSITION_SNAPSHOT_VERSION reference.
- Around line 20-32: Update load_composition to validate WireComposition.version
before invoking Composition::try_from, rejecting any version greater than
COMPOSITION_SNAPSHOT_VERSION with the established error handling path. Preserve
reconstruction for supported and legacy versions.
In `@docs/concepts/loadouts.md`:
- Around line 125-128: Update the hooks documentation near the session
transition hook descriptions to state that hooks are composed in contributor
order, with on_detach and on_destroy executed in reverse contributor order.
Clarify that hooks declared in the project [session] block are included in the
composed hook list.
In `@docs/reference/loadouts.md`:
- Around line 285-291: Update the teardown time-budget description to state that
each teardown transition runs its hooks under a single shared budget, with
on_detach and on_destroy receiving separate budgets; preserve the remaining
timeout and logging behavior.
In `@docs/reference/user-policy.md`:
- Around line 76-82: Add a `### [hooks]` subsection to the policy reference,
documenting hook pattern matching, exact paths, path identity, expansion
behavior, and `allow`/`deny`/`ignore` handling including undecided prompts.
Update “How a contribution is decided” to include hooks in the precedence rules
alongside variables and patches, using the existing schema terminology.
In `@scripts/e2e-attach-pty.sh`:
- Around line 92-101: Fix the elapsed-time accounting in the wait loop around
DEADLINE_SECS so elapsed advances by the actual 0.5-second sleep interval, or
otherwise compare against a wall-clock deadline in seconds. Preserve the
intended 240-second timeout and the existing prompt, return-code, and socat
termination checks.
---
Outside diff comments:
In `@crates/minimald/src/session.rs`:
- Around line 2142-2157: Restore the `patches_upload_lock` documentation
immediately above that method, and move the `composition()` documentation so it
directly precedes `composition()`. Ensure each doc comment describes its
corresponding method and no longer leaves `patches_upload_lock` undocumented.
In `@crates/minimald/src/sessions.rs`:
- Around line 527-573: Update the ManagerMessage::DeleteSession handling to
resolve or start the session actor on the manager mainloop, then spawn a task
that owns the responder and performs hnd.destroy() plus record cleanup
asynchronously. Preserve the existing fallback deletion and error handling,
following the GetWorkspaceDelta pattern so slow teardown does not block handling
other manager messages.
---
Nitpick comments:
In `@crates/minimal/src/loadouts.rs`:
- Around line 244-248: Extract the repeated loadouts path construction into a
loadouts_dir helper returning
resolve_minimal_config_dir(global).join("loadouts"). Update both
resolve_active_loadouts and stage_loadout_hook_scripts to use this helper,
preserving the existing UTF-8 validation and ensuring both loadout files and
script anchors share the same directory.
In `@crates/minimal/src/task.rs`:
- Around line 310-314: Add a `no_hooks` opt-out field to `TaskRunArgs`, expose
the corresponding `--no-hooks` CLI flag, and propagate its inverse
(`!args.no_hooks`) to `hooks_enabled`, `stage_loadout_hook_scripts`, and
`compose_user_contribution` in the task-run flow. Preserve hooks as enabled by
default.
In `@crates/minimald/src/rpc.rs`:
- Around line 1340-1368: Handle unsupported RENAME_EXCHANGE errors in the
swap_result match by treating EINVAL, EOPNOTSUPP, and ENOSYS as fallback cases.
Document the non-atomic fallback, remove the existing dir with remove_dir_all,
then plain-rename staging_dir into dir; preserve the current atomic exchange and
ENOENT paths unchanged.
In `@crates/minimald/src/session.rs`:
- Around line 1346-1375: Update the activation-hook failure branch in the
session transition flow after launch_host_for_hooks(LaunchPhase::Activating) and
before returning the InvalidInput error to tear down the activation host through
the existing host-shutdown mechanism. Ensure cleanup completes before returning
Err, while preserving the current failure message and preventing the sandbox
from remaining active.
In `@crates/sessions/src/core/policy.rs`:
- Around line 528-724: Add a dedicated unit-test block covering
HooksPolicy::expand_with and ExpandedHooksPolicy::check: verify deny takes
precedence over ignore, allow yields Decided(Allowed), unmatched project roots
yield NeedsApproval, and UserLoadout/Package sources remain Allowed/Denied
regardless of patterns. Also test UserPolicy TOML round-tripping for a [hooks]
section and confirm an empty HooksPolicy is omitted during serialization,
following the existing VarsPolicy and PatchesPolicy test patterns.
In `@crates/sessions/src/daemon/composer.rs`:
- Around line 293-305: Update the V::Denied handling to build
ComposeError::Denied::what from the pending ProvenancedHook’s
hook().description(), falling back to "lifecycle hook" when no description
exists; preserve the existing source lookup and InvalidWireItem behavior for
unknown IDs.
In `@crates/sessions/src/wire/policy.rs`:
- Around line 93-119: Add a test alongside the existing verdict round-trip tests
that constructs Approved, Denied, and Ignored WireHookVerdict values with
distinct PendingId values, then passes each through the existing round_trip
helper and asserts equality with the original.
In `@scripts/session-e2e.sh`:
- Around line 864-866: Introduce a shared flag indicating that hook proofs are
initialized, set it when the seed condition enables the hook-proof block
containing hook_log_has, and replace the duplicated seed-condition checks plus
the HOOK_RESTART_SID-only assertion guard with this flag at all three sites.
Ensure hook_log_has is called only when the flag is set, including the restart
assertion path.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 0cbdd9bb-b799-45a6-8a3f-7231f6c51fe7
📒 Files selected for processing (55)
.minimal/minimal.tomlAGENTS.mdcrates/mfile/src/lib.rscrates/mfile/src/package_composable.rscrates/mfile/src/project_composable.rscrates/minimal-client/src/lib.rscrates/minimal-tui/src/rpc.rscrates/minimal-tui/tests/snapshots.rscrates/minimal/src/lib.rscrates/minimal/src/loadouts.rscrates/minimal/src/prompt.rscrates/minimal/src/task.rscrates/minimal/tests/cli.rscrates/minimald-rpc/src/lib.rscrates/minimald/src/hooks.rscrates/minimald/src/lib.rscrates/minimald/src/main.rscrates/minimald/src/nsenter.rscrates/minimald/src/rpc.rscrates/minimald/src/session.rscrates/minimald/src/session_host.rscrates/minimald/src/sessions.rscrates/minimald/src/sessions/composables.rscrates/minimald/src/store.rscrates/minimald/src/test_harness.rscrates/minvmd/examples/exec.rscrates/sessions/docs/COMPOSITION.mdcrates/sessions/example_project/minimal.tomlcrates/sessions/src/client/handler.rscrates/sessions/src/client/hookscripts.rscrates/sessions/src/client/mod.rscrates/sessions/src/core/compose.rscrates/sessions/src/core/expansion.rscrates/sessions/src/core/hooks.rscrates/sessions/src/core/lifecyclehook.rscrates/sessions/src/core/loadout.rscrates/sessions/src/core/policy.rscrates/sessions/src/core/primitives.rscrates/sessions/src/core/source.rscrates/sessions/src/daemon/composer.rscrates/sessions/src/lib.rscrates/sessions/src/store.rscrates/sessions/src/wire/policy.rscrates/sessions/src/wire/primitives.rscrates/sessions/src/wire/request.rscrates/sessions/tests/client_flow1.rscrates/sessions/tests/client_flow2.rsdocs/concepts/loadouts.mddocs/reference/cli-min.mddocs/reference/loadouts.mddocs/reference/minimal-dot-toml.mddocs/reference/user-policy.mdscripts/e2e-attach-pty.pyscripts/e2e-attach-pty.shscripts/session-e2e.sh
💤 Files with no reviewable changes (1)
- scripts/e2e-attach-pty.py
| fn read_script( | ||
| ctx: &HookContext<'_>, | ||
| source: &Source, | ||
| script: &HookScript, | ||
| ) -> Result<Vec<u8>, String> { | ||
| match script.body() { | ||
| HookScriptBody::Inline(body) => Ok(body.clone().into_bytes()), | ||
| HookScriptBody::External(rel) => { | ||
| let staged = staged_script_path(source, rel) | ||
| .ok_or_else(|| "this source cannot declare hook scripts".to_string())?; | ||
| // Both joins below are made with a path derived from wire | ||
| // data, so neither is allowed to climb out of the directory | ||
| // it is anchored to. `staged_script_path` and | ||
| // `ConfigRelPath` each refuse the components that would let | ||
| // it; this is the check at the point of use, where the | ||
| // consequence actually lands — reading a file the session | ||
| // was never given. | ||
| if !contained_relative_path(staged.as_std_path()) | ||
| || !contained_relative_path(rel.as_utf8_path().as_std_path()) | ||
| { | ||
| return Err(format!( | ||
| "hook script path `{staged}` escapes the session's staged-hooks directory" | ||
| )); | ||
| } | ||
| let staged_path = ctx.hooks_dir.as_utf8_path().join(&staged); | ||
| match std::fs::read(staged_path.as_std_path()) { | ||
| Ok(b) => return Ok(b), | ||
| Err(e) if e.kind() != std::io::ErrorKind::NotFound => { | ||
| return Err(format!("reading `{staged_path}`: {e}")); | ||
| } | ||
| Err(_) => {} | ||
| } | ||
| // Not staged. For a project, the script may have arrived | ||
| // with the workspace tree instead. | ||
| if matches!(source, Source::Project { .. }) { | ||
| let in_tree = ctx.workspace.as_utf8_path().join(rel.as_utf8_path()); | ||
| return std::fs::read(in_tree.as_std_path()).map_err(|e| { | ||
| format!( | ||
| "script not staged, and reading `{in_tree}` from the workspace \ | ||
| failed: {e}" | ||
| ) | ||
| }); | ||
| } | ||
| Err(format!( | ||
| "no staged script at `{staged_path}` (was the hook-script upload skipped?)" | ||
| )) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Move the script reads off the async worker.
read_script calls std::fs::read on Line 616 and Line 627. run_one calls it from an async context on Line 456. A stalled mount under the staged-hooks directory or the workspace then stalls a runtime worker thread, and the hook's own timeout cannot bound it because the timeout starts later.
The same applies to the terminal open on Line 488, which uses std::fs::OpenOptions.
Use tokio::fs or tokio::task::spawn_blocking for both.
Based on learnings: "In this Rust repo, avoid blocking filesystem work in async contexts (e.g., don't call std::fs directly from async tasks). If you need filesystem traversal/walks for diagnostic collectors, run that work in a blocking thread via tokio::task::spawn_blocking".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/minimald/src/hooks.rs` around lines 591 - 639, Move the blocking
filesystem operations in read_script and run_one off async runtime workers:
replace the std::fs::read calls for staged and workspace scripts and the
std::fs::OpenOptions terminal open with tokio::fs or
tokio::task::spawn_blocking, propagating their errors while preserving the
existing fallback and timeout behavior.
Source: Learnings
| Hooks are scripts declared to run at session transition points: `on_activate` | ||
| when the session comes up, `on_destroy` when it is torn down, and `on_failure` | ||
| when activation fails. Declare them to warm a cache, fetch grammars, or clean | ||
| up after a failed start: | ||
| when the session comes up, `on_destroy` when it is torn down, `on_attach` | ||
| when you connect to it, and `on_detach` when you disconnect. Declare them to | ||
| warm a cache, fetch grammars, or clean up when you step away: |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Document teardown ordering and project hook composition.
State that hooks combine in contributor order, but on_detach and on_destroy run in reverse contributor order. Also state that project hooks declared in the project [session] block participate in the composed hook list. This prevents users from relying on declaration-order teardown or overlooking project hooks.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/concepts/loadouts.md` around lines 125 - 128, Update the hooks
documentation near the session transition hook descriptions to state that hooks
are composed in contributor order, with on_detach and on_destroy executed in
reverse contributor order. Clarify that hooks declared in the project [session]
block are included in the composed hook list.
| while [ "$elapsed" -lt "$DEADLINE_SECS" ]; do | ||
| if [ -z "$answered" ] && grep -qi -- "$EXIT_PROMPT" "$out" 2>/dev/null; then | ||
| printf '%s' "$answer_key" >&9 | ||
| answered=1 | ||
| fi | ||
| [ -s "$rc" ] && break | ||
| kill -0 "$socat_pid" 2>/dev/null || break | ||
| sleep 0.5 | ||
| elapsed=$((elapsed + 1)) | ||
| done |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
The deadline expires at 120 seconds, not the 240 the name states.
Each iteration sleeps 0.5 seconds but adds 1 to elapsed. So elapsed counts half-seconds while DEADLINE_SECS names seconds. The loop gives up after 120 seconds of wall-clock time.
The comment at Lines 84-86 states the wait must tolerate a slow VM sandbox bring-up. A slow lane that needs more than 120 seconds exits the loop early, records no status, and the script returns exit 1 at Line 118. The caller then reports a failed attach instead of a timeout.
🐛 Proposed fix
answered=""
elapsed=0
-while [ "$elapsed" -lt "$DEADLINE_SECS" ]; do
+# Half-second polls, so the bound is in half-seconds too.
+deadline_ticks=$((DEADLINE_SECS * 2))
+while [ "$elapsed" -lt "$deadline_ticks" ]; do
if [ -z "$answered" ] && grep -qi -- "$EXIT_PROMPT" "$out" 2>/dev/null; then
printf '%s' "$answer_key" >&9
answered=1
fi
[ -s "$rc" ] && break
kill -0 "$socat_pid" 2>/dev/null || break
sleep 0.5
elapsed=$((elapsed + 1))
done📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| while [ "$elapsed" -lt "$DEADLINE_SECS" ]; do | |
| if [ -z "$answered" ] && grep -qi -- "$EXIT_PROMPT" "$out" 2>/dev/null; then | |
| printf '%s' "$answer_key" >&9 | |
| answered=1 | |
| fi | |
| [ -s "$rc" ] && break | |
| kill -0 "$socat_pid" 2>/dev/null || break | |
| sleep 0.5 | |
| elapsed=$((elapsed + 1)) | |
| done | |
| while [ "$elapsed" -lt "$DEADLINE_SECS" ]; do | |
| if [ -z "$answered" ] && grep -qi -- "$EXIT_PROMPT" "$out" 2>/dev/null; then | |
| printf '%s' "$answer_key" >&9 | |
| answered=1 | |
| fi | |
| [ -s "$rc" ] && break | |
| kill -0 "$socat_pid" 2>/dev/null || break | |
| sleep 0.5 | |
| elapsed=$((elapsed + 1)) | |
| done |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/e2e-attach-pty.sh` around lines 92 - 101, Fix the elapsed-time
accounting in the wait loop around DEADLINE_SECS so elapsed advances by the
actual 0.5-second sleep interval, or otherwise compare against a wall-clock
deadline in seconds. Preserve the intended 240-second timeout and the existing
prompt, return-code, and socat termination checks.
6232699 to
b28b65f
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (3)
crates/minimal/src/lib.rs (3)
2622-2640: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueHandle an inline body whose first line is blank.
render_hook_scripttakes only the first line. If the script starts with a blank line or a shebang-free leading newline, the row shows an empty body with a trailing ellipsis. Use the first non-empty line instead.♻️ Proposed change
WireHookScript::Inline { body, timeout_secs } => { - let first = body.lines().next().unwrap_or("").trim(); - let shown = if body.lines().count() > 1 { + let mut lines = body.lines().map(str::trim).filter(|l| !l.is_empty()); + let first = lines.next().unwrap_or(""); + let shown = if lines.next().is_some() { format!("{first} …") } else { first.to_string() };🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/minimal/src/lib.rs` around lines 2622 - 2640, Update render_hook_script’s inline-body preview to select the first non-empty trimmed line instead of always using body.lines().next(), while preserving the ellipsis behavior for multi-line bodies and the existing empty preview when no non-empty line exists.
2585-2603: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider printing the events in lifecycle order and adding a header row.
The loop prints
on_activate,on_destroy,on_attach,on_detach.on_destroyappears before the attach and detach events, so the row order does not match the session lifecycle order described in the doc comment. A short header row would also make the unlabeled columns (event, type, timeout, source) readable.♻️ Proposed ordering change
for (event, script) in [ ("on_activate", hook.on_activate.as_ref()), - ("on_destroy", hook.on_destroy.as_ref()), ("on_attach", hook.on_attach.as_ref()), ("on_detach", hook.on_detach.as_ref()), + ("on_destroy", hook.on_destroy.as_ref()), ] {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/minimal/src/lib.rs` around lines 2585 - 2603, Update the hook-printing loop to emit events in lifecycle order: on_activate, on_attach, on_detach, then on_destroy. Add a concise header row before iterating hooks that labels the event, type, timeout, and source columns, while preserving the existing description and body output.
240-249: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd parse tests for
session hooksand--no-hooks.The file tests
setup-zedparsing but not the two new surfaces. Add tests thatmin session hooks <session> --jsonparses intoHooksArgs, and thatno_hooksdefaults tofalseand is set by--no-hooks. These tests protect the flag that disables hook execution for the whole session lifetime.Also applies to: 489-497
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/minimal/src/lib.rs` around lines 240 - 249, Add parser tests covering the new session hook CLI surfaces: verify `session hooks <session> --json` parses into `HooksArgs` with the session and JSON flag set, and verify `no_hooks` defaults to false and becomes true when `--no-hooks` is provided. Place these alongside the existing `setup-zed` parsing tests and use the existing argument-parsing test conventions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@crates/minimal/src/lib.rs`:
- Around line 2622-2640: Update render_hook_script’s inline-body preview to
select the first non-empty trimmed line instead of always using
body.lines().next(), while preserving the ellipsis behavior for multi-line
bodies and the existing empty preview when no non-empty line exists.
- Around line 2585-2603: Update the hook-printing loop to emit events in
lifecycle order: on_activate, on_attach, on_detach, then on_destroy. Add a
concise header row before iterating hooks that labels the event, type, timeout,
and source columns, while preserving the existing description and body output.
- Around line 240-249: Add parser tests covering the new session hook CLI
surfaces: verify `session hooks <session> --json` parses into `HooksArgs` with
the session and JSON flag set, and verify `no_hooks` defaults to false and
becomes true when `--no-hooks` is provided. Place these alongside the existing
`setup-zed` parsing tests and use the existing argument-parsing test
conventions.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 804f0308-73d8-4013-b349-21c6d9a7a2d5
📒 Files selected for processing (14)
crates/minimal-client/src/lib.rscrates/minimal/src/lib.rscrates/minimal/tests/cli.rscrates/minimald-rpc/src/lib.rscrates/minimald/src/rpc.rscrates/minimald/src/test_harness.rscrates/minvmd/examples/exec.rscrates/minvmd/tests/minimald_session_integration.rscrates/sessions/src/lib.rscrates/sessions/src/store.rscrates/sessions/src/wire/policy.rscrates/sessions/src/wire/primitives.rscrates/sessions/src/wire/request.rscrates/sessions/tests/client_flow1.rs
🚧 Files skipped from review as they are similar to previous changes (13)
- crates/minvmd/tests/minimald_session_integration.rs
- crates/minimald/src/test_harness.rs
- crates/minvmd/examples/exec.rs
- crates/sessions/tests/client_flow1.rs
- crates/sessions/src/wire/policy.rs
- crates/sessions/src/lib.rs
- crates/sessions/src/store.rs
- crates/minimal/tests/cli.rs
- crates/minimald-rpc/src/lib.rs
- crates/sessions/src/wire/request.rs
- crates/minimal-client/src/lib.rs
- crates/sessions/src/wire/primitives.rs
- crates/minimald/src/rpc.rs
32ff526 to
6870012
Compare
…merge main) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…erge Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolves https://github.com/gominimal/inbox/issues/201
Summary
Sessions can now run scripts at their four transition points —
on_activate,on_attach,on_detach,on_destroy— declared either in a loadout or in aproject's
[session]block. Hooks execute inside the running session byjoining its namespaces, so they see the same packages, variables, files, and
network the user does.
timeout(default 60s, capped at 300s — a hook is arbitrary code, so anunbounded one could hold a session open). Hooks concatenate in contributor
order and run in reverse for teardown, so a project sets up before your
loadouts and tears down after them.
shby default, or whatever a leading shebang names,parsed exactly as
execve(2)would — so a hook can be fish or Python withoutbehaving differently here than anywhere else.
they run only once the user allow-lists the project in their
[hooks]policy; a loadout's are the user's own file and are ungated. Apackage cannot declare hooks at all, and the gate denies any that appear.
on_activatefails the activation — adevelopment environment whose setup script failed is not the environment
that was asked for. The other three never block their transition: a session
must always be attachable and always destroyable. Teardown additionally runs
under one shared time budget across all its hooks.
min session hooks <session>lists what a sessioncarries and where each hook came from, read from the persisted composition
snapshot so it answers for stopped sessions and survives a daemon restart.
min session activate --no-hooksdrops them at compositiontime, so the session records that it has none rather than carrying hooks
every later transition has to remember to skip.
Also lands the e2e coverage for the above, which found three bugs that unit
tests structurally could not: a project's external hook script could never
activate (the finalize gate demanded an upload the client does not send for
project-sourced scripts),
on_destroywas silently skipped for any sessionwhose actor was idle (every session, after a daemon restart), and "Allow
permanent" stored the project path as a glob pattern, so a path containing
metacharacters either matched nothing or matched its siblings.
The session e2e's pty driver moved from Python to socat — see the dependency
note in the checklist below.
Testing
just e2e-native— the full session e2e against a host-native daemon,including the new hooks blocks and the rewritten pty driver:
Unit and integration tests across the touched crates:
Static gates:
Each regression test was confirmed to fail against the pre-fix behaviour
before being kept — e.g. reverting the destroy fix yields "destroying an idle
session skipped its on_destroy hook".
Not run:
just e2e(the VM lane). This box has no/dev/kvm, so theVM-backed path is unverified locally; the e2e is lane-agnostic and the native
lane exercises the identical script.
Checklist
Docs updated if behavior changed
docs/reference/loadouts.md(hook semantics, interpreter/shebang rules,metadata variables, teardown budget),
docs/reference/minimal-dot-toml.md,docs/reference/user-policy.md,docs/reference/cli-min.md,docs/concepts/loadouts.md, andAGENTS.md.BREAKING CHANGE:footer present if this is a breaking changeNot breaking.
on_failure— an earlier spelling that never executed — isrejected by name with an error pointing at
on_destroy, rather than beingsilently ignored.
Reviewer notes
New host dependency:socat. The e2e drives its interactive attachesthrough a real pty, because only a tty can answer the session-exit prompt.
That driver was Python (an undeclared assumption that happens to hold on
hosted runners); it is now
scripts/e2e-attach-pty.sh. socat is present onstock Linux but not stock macOS — the self-hosted Apple Silicon runner
needs
brew install socatbefore the macOS lane will pass. Documented inAGENTS.md; I could not add an install step because
.github/workflows/isfrozen.
crates/sessions/src/core/policy.rsgainsHooksPolicyas a near-copy ofPatchesPolicy. Left deliberately un-unified — the two are incidentally thesame shape today and are expected to diverge.
Note
Add lifecycle hooks (on_activate, on_destroy, on_attach, on_detach) to sessions
minimal.tomlfiles and composed alongside vars and patches.[hooks]policy (allow/deny/ignore) that gates project-declared hooks; interactive mode prompts for approval and persists decisions touser_policy.toml; packages cannot declare hooks.WorkspaceHookScriptsTarZstSSH subsystem before finalization.min activate --no-hooksdisables all hooks for a session; the choice persists across attach, detach, and destroy transitions.min session hooksCLI subcommand to list composed hooks for a running or idle session, with--jsonoutput support.FinalizeSessionwill succeed; missing the upload returns anInvalidInputerror instructing the client to upload scripts first.Macroscope summarized dfd3e6d.
Summary by CodeRabbit
New Features
session hooksto view configured hooks in table or JSON format.session activate --no-hooks, persisted across later transitions.Documentation