Skip to content

Commit 50a6da4

Browse files
committed
feat(policy): /wait awaits local policy reload; demo auto-approves redrafts
Three things in one commit, all surfaced by running the demo end-to-end against a real gateway and finding the agent had to draft a broader second proposal. 1. /wait race fix. Previously /wait returned `approved` the moment it observed the gateway's chunk status flip, but the local supervisor reloads policy on its own poll cycle (~10s in practice). The agent's retry would race the reload and hit the still-old policy, getting denied. Codex then drafted a broader rule and re-submitted — sound agent behavior, but not what /wait should provoke. Now /wait captures the local policy version at start, and after observed-approved waits for the supervisor to load a strictly-newer version before returning. Bounded by the caller's deadline; best-effort return if the deadline elapses without the version bumping. Two new unit tests pin the happy path and the deadline-clamped fallback. 2. demo.sh auto-approve loop. Replaces approve_when_pending + wait_for_agent with one approve_pending_until_agent_exits function that keeps watching for pending chunks and approving them until the agent process exits (or the configured timeout). Defense in depth against future redraft scenarios for any reason; today (post-fix #1) the agent should only submit one proposal per task, but we don't want to hang silently if it does submit more. 3. UX. Step headers now carry "[t+1.2s]" relative timestamps so reading the run output makes latency visible (the demo's whole point is the wait is cheap — surface that). A spin_wait helper renders an ASCII spinner during the watch loop so the demo never looks frozen on a TTY. Falls back to plain sleep on non-TTY contexts. Closes the race condition diagnosed from the trace timing where the gateway approved at t+0, sandbox observed at t+0.3s, but the supervisor didn't load v2 until t+9.4s — well after the agent had already retried and been denied. Signed-off-by: Alexander Watson <zredlined@gmail.com>
1 parent e6f39f5 commit 50a6da4

2 files changed

Lines changed: 194 additions & 22 deletions

File tree

crates/openshell-sandbox/src/policy_local.rs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,9 +680,28 @@ async fn proposal_wait_response(
680680
};
681681
let timeout_secs = parse_timeout_query(query);
682682
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
683+
// Baseline local policy version at /wait start. When a chunk is
684+
// approved upstream the gateway merges it immediately, but the local
685+
// supervisor reloads policy on its own poll cycle — without this
686+
// baseline we can return "approved" before the local rule is active,
687+
// and the agent's single retry races the reload.
688+
let baseline_policy_version: u32 = ctx
689+
.current_policy
690+
.read()
691+
.await
692+
.as_ref()
693+
.map_or(0, |p| p.version);
683694
loop {
684695
match fetch_chunk(&session, chunk_id).await {
685696
Ok(Some(chunk)) if is_terminal_status(&chunk.status) => {
697+
if chunk.status == "approved" {
698+
// Don't promise the agent its retry will succeed until
699+
// the local supervisor has actually loaded the new
700+
// policy. Bounded by the same deadline so a stuck
701+
// supervisor cannot extend the wait beyond what the
702+
// caller asked for.
703+
wait_for_local_policy_bump(ctx, baseline_policy_version, deadline).await;
704+
}
686705
// Audit beat: emit at the moment this sandbox observes the
687706
// decision so the trace correlates with the proxy events
688707
// bracketing the loop. Multiple waiters on the same chunk
@@ -753,6 +772,42 @@ fn is_terminal_status(status: &str) -> bool {
753772
matches!(status, "approved" | "rejected")
754773
}
755774

775+
/// After a chunk is approved upstream, wait until the local supervisor has
776+
/// loaded a policy version strictly newer than the baseline captured at the
777+
/// start of `/wait`. Bounded by the caller-supplied deadline; returns early
778+
/// (best-effort) if the deadline passes without the version bumping.
779+
///
780+
/// The polling cadence here is faster than `PROPOSAL_WAIT_POLL_INTERVAL`
781+
/// (which paces upstream gateway calls). This loop only reads in-memory
782+
/// state, so 200ms gives a responsive handoff to the agent's retry once
783+
/// the supervisor's own policy poll catches up.
784+
async fn wait_for_local_policy_bump(
785+
ctx: &PolicyLocalContext,
786+
baseline_version: u32,
787+
deadline: tokio::time::Instant,
788+
) {
789+
const TICK: std::time::Duration = std::time::Duration::from_millis(200);
790+
loop {
791+
let current: u32 = ctx
792+
.current_policy
793+
.read()
794+
.await
795+
.as_ref()
796+
.map_or(0, |p| p.version);
797+
if current > baseline_version {
798+
return;
799+
}
800+
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
801+
if remaining.is_zero() {
802+
// Best effort: return approved anyway. The agent's retry may
803+
// race the reload, but the alternative (extending past the
804+
// caller's deadline) violates the wait contract.
805+
return;
806+
}
807+
tokio::time::sleep(std::cmp::min(remaining, TICK)).await;
808+
}
809+
}
810+
756811
/// Parse `?timeout=<s>` from the query string. Default applies for missing
757812
/// or unparseable values; bounds clamp to keep the agent's hold ceiling
758813
/// sane. Re-issue is the right pattern for longer waits.
@@ -1665,6 +1720,64 @@ mod tests {
16651720
assert!(summary.contains("/usr/bin/curl"));
16661721
}
16671722

1723+
#[tokio::test]
1724+
async fn wait_for_local_policy_bump_returns_when_version_advances() {
1725+
let ctx = PolicyLocalContext::new(
1726+
Some(ProtoSandboxPolicy {
1727+
version: 5,
1728+
..Default::default()
1729+
}),
1730+
None,
1731+
None,
1732+
);
1733+
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
1734+
let bumped = {
1735+
let policy = ctx.current_policy.clone();
1736+
tokio::spawn(async move {
1737+
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1738+
*policy.write().await = Some(ProtoSandboxPolicy {
1739+
version: 6,
1740+
..Default::default()
1741+
});
1742+
})
1743+
};
1744+
let start = tokio::time::Instant::now();
1745+
wait_for_local_policy_bump(&ctx, 5, deadline).await;
1746+
bumped.await.unwrap();
1747+
// Returned promptly after the bump, well before the 2s deadline.
1748+
let elapsed = start.elapsed();
1749+
assert!(
1750+
elapsed < std::time::Duration::from_millis(800),
1751+
"should return shortly after version bumps; took {elapsed:?}"
1752+
);
1753+
}
1754+
1755+
#[tokio::test]
1756+
async fn wait_for_local_policy_bump_returns_at_deadline_if_no_advance() {
1757+
let ctx = PolicyLocalContext::new(
1758+
Some(ProtoSandboxPolicy {
1759+
version: 5,
1760+
..Default::default()
1761+
}),
1762+
None,
1763+
None,
1764+
);
1765+
let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(300);
1766+
let start = tokio::time::Instant::now();
1767+
wait_for_local_policy_bump(&ctx, 5, deadline).await;
1768+
let elapsed = start.elapsed();
1769+
// Best effort: returns at or shortly after the deadline rather
1770+
// than extending past it.
1771+
assert!(
1772+
elapsed >= std::time::Duration::from_millis(250),
1773+
"should wait until ~deadline; only waited {elapsed:?}"
1774+
);
1775+
assert!(
1776+
elapsed < std::time::Duration::from_millis(800),
1777+
"should not extend past deadline by much; took {elapsed:?}"
1778+
);
1779+
}
1780+
16681781
#[test]
16691782
fn sanitize_reason_for_audit_strips_control_chars_and_caps_length() {
16701783
// Tabs and newlines are stripped; ordinary printable chars survive;

examples/agent-driven-policy-management/demo.sh

Lines changed: 81 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,50 @@ RESET=$'\033[0m'
7373

7474
AGENT_PID=""
7575

76-
step() { printf "\n${BOLD}${CYAN}==> %s${RESET}\n\n" "$1"; }
76+
# Wall-clock anchor so each step header can carry a "[t+1.2s]" tag and the
77+
# reader sees where time is going. `date +%s.%N` works on macOS bash where
78+
# `${EPOCHREALTIME}` may be unavailable in older bashes.
79+
DEMO_START_EPOCH="$(date +%s.%N)"
80+
81+
elapsed() {
82+
awk -v s="$DEMO_START_EPOCH" -v now="$(date +%s.%N)" \
83+
'BEGIN { printf "%.1fs", now - s }'
84+
}
85+
86+
step() {
87+
printf "\n${BOLD}${CYAN}==> [t+%s] %s${RESET}\n\n" "$(elapsed)" "$1"
88+
}
7789
info() { printf " %b\n" "$*"; }
7890

91+
# ASCII spinner for the watch-for-pending loop. Renders only on a TTY so
92+
# piped runs (CI, tee, etc.) stay clean. spin_wait pairs a message with a
93+
# bounded sleep so the spinner animates smoothly without polling faster
94+
# than necessary.
95+
SPINNER_CHARS=( '' '' '' '' '' '' '' '' '' '' )
96+
SPINNER_IDX=0
97+
98+
spin_wait() {
99+
local message="$1"
100+
local duration_secs="${2:-2}"
101+
if [[ ! -t 1 ]]; then
102+
sleep "$duration_secs"
103+
return
104+
fi
105+
local end=$(( SECONDS + duration_secs ))
106+
while (( SECONDS < end )); do
107+
printf "\r ${DIM}%s${RESET} %s " \
108+
"${SPINNER_CHARS[SPINNER_IDX]}" "$message"
109+
SPINNER_IDX=$(( (SPINNER_IDX + 1) % ${#SPINNER_CHARS[@]} ))
110+
sleep 0.1
111+
done
112+
}
113+
114+
spin_clear() {
115+
if [[ -t 1 ]]; then
116+
printf "\r%*s\r" "${COLUMNS:-100}" ''
117+
fi
118+
}
119+
79120
# Redact host-side credentials from the agent log tail before printing on
80121
# failure. Codex shouldn't echo the token, but a misbehaving tool call (e.g.,
81122
# `curl -v`) could leak it; sanitize before showing the log.
@@ -204,9 +245,19 @@ check_gateway() {
204245
local raw version
205246
# `openshell status` colorizes labels with ANSI even when piped, so strip
206247
# escapes before parsing. Use NO_COLOR as a belt-and-suspenders hint for
207-
# libraries that respect it.
208-
raw="$(NO_COLOR=1 "$OPENSHELL_BIN" status 2>/dev/null \
209-
| sed 's/\x1b\[[0-9;]*m//g')"
248+
# libraries that respect it. Capture stderr explicitly so a connection
249+
# failure (gateway down, port-forward died after a redeploy) surfaces a
250+
# real error message instead of `set -euo pipefail` silently exiting.
251+
if ! raw="$(NO_COLOR=1 "$OPENSHELL_BIN" status 2>&1)"; then
252+
fail "openshell could not reach the gateway. CLI output:
253+
${raw}
254+
255+
If you just redeployed, the kubectl port-forward you backgrounded earlier
256+
probably died with the old pod. Restart it (silenced so its noise doesn't
257+
bleed into the demo):
258+
KUBECONFIG=kubeconfig kubectl -n openshell port-forward svc/openshell 8090:8080 >/dev/null 2>&1 &"
259+
fi
260+
raw="$(sed 's/\x1b\[[0-9;]*m//g' <<<"$raw")"
210261
version="$(awk -F': *' '/Version:/ { print $2; exit }' <<<"$raw")"
211262
[[ -n "$version" ]] \
212263
|| fail "active OpenShell gateway is not reachable; start one with: openshell gateway start"
@@ -357,48 +408,57 @@ EOF
357408
info "${DIM}Watching for the pending draft on the gateway...${RESET}"
358409
}
359410

360-
approve_when_pending() {
411+
approve_pending_until_agent_exits() {
361412
step "Waiting for the agent to draft a policy proposal"
362413
narrate_sandbox_workflow
363414

364-
local start now pending
415+
local start now pending approval_count
365416
start="$(date +%s)"
366417
pending="${TMP_DIR}/pending.txt"
418+
approval_count=0
367419

368420
while true; do
421+
# Agent finished? Drain its exit status and we're done.
369422
if ! kill -0 "$AGENT_PID" >/dev/null 2>&1; then
370-
wait "$AGENT_PID" || true
423+
spin_clear
424+
if ! wait "$AGENT_PID"; then
425+
AGENT_PID=""
426+
fail "agent run failed"
427+
fi
371428
AGENT_PID=""
372-
fail "agent exited before a pending proposal appeared"
429+
if (( approval_count == 0 )); then
430+
fail "agent exited before any pending proposal appeared"
431+
fi
432+
info "agent exited after ${approval_count} approval(s)"
433+
return
373434
fi
374435

436+
# Anything pending? Approve and keep watching — the agent may
437+
# redraft if a previous proposal didn't yield the access it needed.
375438
if "$OPENSHELL_BIN" rule get "$DEMO_SANDBOX_NAME" --status pending >"$pending" 2>/dev/null \
376439
&& grep -q "Chunk:" "$pending" && grep -q "pending" "$pending"; then
440+
spin_clear
377441
info ""
378442
info "${GREEN}proposal received:${RESET}"
379443
summarize_pending "$pending"
380444

381445
step "Approving — the agent's /wait will return within ~1s"
382446
"$OPENSHELL_BIN" rule approve-all "$DEMO_SANDBOX_NAME" \
383447
| awk '/approved/ { print " " $0 }'
384-
return
448+
approval_count=$((approval_count + 1))
385449
fi
386450

387451
now="$(date +%s)"
388452
if (( now - start >= DEMO_APPROVAL_TIMEOUT_SECS )); then
389-
fail "timed out waiting for the agent to submit a policy proposal"
453+
spin_clear
454+
if (( approval_count == 0 )); then
455+
fail "timed out waiting for the agent to submit a policy proposal"
456+
fi
457+
fail "agent did not exit within ${DEMO_APPROVAL_TIMEOUT_SECS}s after ${approval_count} approval(s)"
390458
fi
391-
sleep 2
392-
done
393-
}
394459

395-
wait_for_agent() {
396-
if ! wait "$AGENT_PID"; then
397-
AGENT_PID=""
398-
fail "agent run failed"
399-
fi
400-
AGENT_PID=""
401-
info "agent's /wait returned approved — single PUT retry succeeded"
460+
spin_wait "watching for pending proposals (approved ${approval_count} so far)" 2
461+
done
402462
}
403463

404464
verify_github_write() {
@@ -457,8 +517,7 @@ main() {
457517
show_run_summary
458518

459519
start_agent_sandbox
460-
approve_when_pending
461-
wait_for_agent
520+
approve_pending_until_agent_exits
462521
verify_github_write
463522
show_logs
464523

0 commit comments

Comments
 (0)