Cover the remaining role-carrying events with test_sink assertions:
session.started (ctx-held role), session.resumed and session.ended
(session-held role), turn.interrupted and turn.failed. Correct the
mcp_factory race comment (the loser fires a second plain connected and
overwrites the winner's map entry, not a reconnect) and the role_extras
doc (derived roles fall through to the session-held role).
- McpFactory::acquire now reads the live probe and the reconnect flag
under one lock section so a racing first connect cannot be
misreported as COYOTE_MCP_RECONNECT=true; the residual window across
the spawn await (no singleflight) is documented. try_get_active,
now unused, is removed.
- All three RAG sync funnels (init, init_with_config,
refresh_document_paths) fire rag.sync.completed only after a
successful save(), matching the documented contract; a save error
routes through rag.sync.failed with COYOTE_ERROR before propagating.
- New funnel-level tests pin init_with_config's started-then-completed
ordering, the failed event on a save error, and the failed event on
the sync Err arm that a user abort surfaces through.
- Register five new events (26 -> 31): rag.sync.started/completed/failed
and mcp.server.connected/failed, whitelist-gated for agent global hooks
like every non-escalation event
- Bracket every real RAG document sync with rag.sync.* via pre-resolved
RagSyncHooks snapshots carried into the build funnels; cache hits, pure
loads, and attaches fire nothing, and a user abort mid-sync counts as a
failure
- Fire mcp.server.connected only on real spawns in McpFactory::acquire
(reusing a live server is silent), marking respawns of a previously
live key with COYOTE_MCP_RECONNECT; mcp.server.failed carries
COYOTE_ERROR and COYOTE_MCP_AUTH_REQUIRED on auth failures. Boot-time
registry connects deliberately fire nothing
- Carry COYOTE_ROLE on session.* and turn.* events, resolved at fire
time so mid-session role switches and temp roles are reflected
The advisory flock in run_checks.py uses one system-wide lock path
(~/.cache/coyote/review-stack.lock). Concurrent runs of the timeout/deadline
tests contend on it: the losers sleep in the bounded retry loop, the total
deadline (computed before lock acquisition) expires, and commands record
'skipped: deadline' instead of the expected timeout/kill behavior.
- run_checks.py: ADVERSARY_STACK_LOCK_PATH env override relocates the lock
file (isolation seam, documented alongside the other env seams); every
other lock semantic (default path, bounded wait, proceed-unlocked warning,
fd held for process lifetime, skip when no commands) is unchanged.
- run_checks.py: guard the POSIX-only fcntl import — windows-latest CI has
python3 and runs these tests, so the unconditional import crashed the
script there; on non-POSIX the lock is now skipped with a stderr WARN,
matching the script's documented non-POSIX limitations.
- tests: the shared adversary_script_command helper points every spawned
script at its own throwaway lock path, isolating the run_checks tests from
each other and from any real review running on the same machine.
- tests: adversary_run_checks_stack_lock_path_is_env_overridable pins the
override (lock file appears at the overridden path across two sequential
runs; no lock file is created when no commands are declared).
cmd opens '>>' redirection targets write-exclusive, so when the STARTED
and terminal hook children appended to one shared log concurrently, the
loser of the open race hit a sharing violation and silently dropped its
marker line -- a permanent loss that surfaced as an intermittent
Windows-CI-only failure of the macro-bracketing suite (the engine's exit
drain was verified sound: spawn intent is registered synchronously at
fire time and covers macro-context fires). One file per event makes
writer overlap impossible while keeping every exact-count assertion
byte-identical.
Hook processes are fire-and-forget: the binary's exit drain acknowledges
spawns only, so the STARTED writer can land after the terminal one. The
macro-bracketing tests waited only for the terminal marker and then
asserted STARTED's count immediately, flaking on Windows CI and loaded
Linux runners. The bounded wait now covers every expected marker before
the exact-count and absence assertions run; the probe script's scenario K
comment is corrected to state the spawn-ack-only contract it already
waits around.
The fixture's hook commands were sh-only: single-quoted redirect targets
are literal under cmd /C, so the marker log never received content and
all six tests timed out on windows-latest. Build the marker command
per-platform — parenthesized `(echo MARKER)>> "log"` on Windows to
avoid cmd's trailing-space echo gotcha — and move the interpolated
paths into single-quoted YAML scalars so Windows backslashes are not
read as double-quoted-scalar escapes. Also replace the cleanup call
that a wait timeout skipped with a drop guard, so failed runs no
longer leak temp dirs.
Windows links the process main thread with a ~1 MiB stack (unix defaults
to 8 MiB). Coyote's root async future is a compiler-generated state
machine whose debug-build poll frames blow through 1 MiB the moment
block_on polls it, so every invocation of the binary on Windows died at
startup with STATUS_STACK_OVERFLOW (0xC00000FD) before doing any work.
Replace #[tokio::main] with an explicit bootstrap: main() spawns a
dedicated thread (named "main", 16 MiB stack) that builds the same
multi-thread enable_all runtime and block_on's the async body, then joins
and propagates the Result unchanged; panics are re-raised on the real
main thread via resume_unwind without re-invoking the panic hook, so
panic output and exit codes stay identical. Runtime worker threads get
the same 16 MiB headroom since spawned tasks embed the same class of
giant state machine.
The bootstrap is unconditional rather than cfg(windows)-gated: the unix
arrangement is behaviorally identical (same runtime flavor and driver
set — tokio's ctrl_c signal driver is thread-agnostic), and one shared
code path removes the entire small-main-stack class everywhere instead
of special-casing one OS. Verified on Linux with the full test suite
(inspection_flags 12/12) and the hooks usage probe end-to-end, including
the terminal ctrl-c -> agent.interrupted scenario.
On NTFS the fixture self-collides: .builtin-manifest auto-generates the
8.3 short name BUILTI~1, so writing that fixture file opens the manifest
through its alias and clobbers it -- the exact threat the guard models.
Gate the fixture-based test to unix, and add a windows counterpart that
proves the collision where 8.3 generation is enabled and asserts the
filter invariant (alias-shaped entries are neither removal candidates
nor claimed) either way.
Pure --mcp-list never reads secrets (only --mcp-add does), yet the mcp
action path always ran strict Vault::init, so a virgin environment with
no password file exited 1 instead of printing the empty listing. Route
the mcp_inspect path through the established Vault::init_lenient
constructor; get/remove/add stay on the strict init.
Make tests/inspection_flags.rs hermetic against host state: every
spawned binary gets a fresh fake HOME (and USERPROFILE) so a real
~/.coyote_password can't mask virgin-environment regressions, scrubs
IS_SANDBOX/COYOTE_PROVIDER/COYOTE_PLATFORM outside the sandbox-mode
tests, and pins CWD so workspace mcp.json discovery can't leak in.
New tests pin both sides of the boundary: the lenient list path (empty
and configured, never bootstrapping a password file) and the strict
vault gate for get/remove/add.
Add a #[cfg(windows)] dispatch test module exercising the real cmd /C
raw_arg path: env delivery through a verbatim command line, fire()
detachment via a gate-file batch loop, payload-file lifecycle with the
per-user temp-dir posture assertion, backslashed COYOTE_HOOKS_DIR
install override, per-scope cwd resolution with an end-to-end %CD%
check, and exec-bit-free install. Hoist wait_for to shared test scope
and move the two OS-agnostic dispatch tests (spawn-failure payload
cleanup, debug-only error paths) into an ungated dispatch_portable
module so both cfg lanes share them instead of duplicating.
The unknown-entry diagnostic only inspected gate entries prefixed by the
current event's full name, so a partial glob like 'tool.st*' — which
extends no event name at all — was silently inert on every event.
Entries containing '*' now validate against the wildcard grammar
(*, *.*, *.<name>, <event>.* over a real event) and anything outside it
gets the same debug-level diagnostic; exact entries keep the existing
event-prefix path unchanged.
Scenario K pins the global_hooks wildcard forms (exact, <event>.*, *.<name>, *.*) and partial-glob inertness against a real binary; Scenario L proves --macro without --agent fires zero agent.* events.
The --macro dispatch arm now opens the top-level agent bracket before
macro_execute and closes it signal-aware afterwards, draining pending
hooks like the --execute arm; both bracket calls no-op without a loaded
agent, so agent-less macro runs are unaffected. The isolated arm of
macro_execute closes the bracket an in-macro .agent step opens on its
fresh context, on the step-failure path too. Binary-level tests pin
agent.started plus exactly one terminal event for the --agent flag and
for in-macro .agent steps in both isolation modes, on success and
failure.
Whitelist entries now admit hooks via <event>.* (every hook of that
event), *.<name> (that hook name on every event), and * / *.*
(everything), alongside the unchanged exact <event>.<name> form.
Partial globs like tool.st* are not wildcards: they admit nothing and
still land in the unknown-entry debug diagnostics, while <event>.*
entries are exempt from them. Escalation events keep resolving ungated
regardless of whitelist content.
- strip interior NUL bytes from hook env values in fire_resolved so
agent-authored text (e.g. escalation questions) cannot suppress a hook
via a failed spawn; raise the spawn-failure log to warn!
- make Supervisor::has_active_tasks recurse into child supervisors,
mirroring cancel_recursive, so a prompt ctrl-c that cancels only a
grandchild still latches as an interruption
- document the signal-first vs error-first classification asymmetry at
top_level_agent_finished / SpawnResultHooks::fire and the deliberate
REPL teardown cancel; pin ctrlc-with-no-error => agent.interrupted
- probe Scenario J: pty-driven ctrl-c during a live headless --agent run
fires exactly one agent.interrupted with no COYOTE_AGENT_ERROR
Ctrl-C at the reedline prompt no longer latches the abort signal
unconditionally: latch_prompt_interrupt only marks an interruption when
the supervisor had live agents/jobs to cancel (new
Supervisor::has_active_tasks), so the advertised exit path (ctrl-c at an
idle prompt, then ctrl-d) fires agent.completed at teardown instead of
agent.interrupted. Mid-turn ctrl-c still latches directly via
wait_user_interrupt/poll_abort_signal, preserving the
interrupted-then-exit classification.
Also: probe-lane tests for escalation.answered single-fire,
escalation question env truncation, agent.failed inverse gate, and
hooks probe Scenario I (session.started vs session.resumed); comment
notes on escalation.answered semantics and the agent__cancel blind
spot.
- escalation.raised fires in the child ctx after queue submit; escalation.answered
fires in the parent ctx from the taken request, so both agree on origin envs
- escalation.* resolves global hooks ungated: the per-agent global_hooks whitelist
never applies to these root-level events
- session.resumed fires on the use_session resume leg, inverse of session.started
- agent.interrupted replaces agent.completed/failed when the abort signal was
ctrl-c'd: top-level bracket (now abort-aware), spawned-subagent result arm
(agent__cancel / REPL ctrl-c), and graph agent nodes via the session signal;
exit_agent and the shell-exit path stay non-interrupted by passing no signal
- document the deliberate non-fire paths (direct prompts, escalation timeout and
parent-drop, graph approval nodes)
Two conformance gaps were tests-only:
- install_builtin_hooks warning: a #[serial] test proves the canonicalized
compare stays quiet when COYOTE_HOOKS_DIR merely respells the default
(via a '..' hop and, on unix, a symlink), and a second test proves a
genuine override still warns on a fresh install where the default hooks
dir does not exist (fallback compare).
- run_hook payload cleanup: a dispatch test fires a hook with a payload
and an unspawnable cwd, anchors on the spawn-failure debug log (and the
absence of a payload-write failure) so the assertion cannot pass
vacuously, then polls with a bounded deadline until the payload file is
unlinked.
Both tests were verified red-capable: reverting the canonicalization or
skipping the unlink on the error branch makes them fail.