feat(executor): expose ctx.trigger_depth and pin the invoke depth bump

`invoke_callee_sees_incremented_depth` asserted NOTHING — it ended `let _ = resp`
with a comment that `ctx.trigger_depth` wasn't exposed, so it would have passed
even if `invoke` forwarded the depth unchanged, i.e. never incrementing the
chain-depth counter that bounds runaway trigger loops.

`build_ctx_map` now surfaces `ctx.trigger_depth` (read-only: 0 for direct ingress,
+1 per synchronous `invoke` re-entry or dispatched handler) — the value the
author intended to read and a legitimate diagnostic for a script. The test now
pins the increment: the caller is depth 0, the invoke callee must see 1.

Mutation-verified: forwarding the depth unchanged makes the callee see 0 and the
test fails.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-15 19:38:31 +02:00
parent 36272c8dac
commit 95d6b95272
2 changed files with 20 additions and 5 deletions

View File

@@ -498,6 +498,14 @@ fn build_ctx_map(req: &ExecRequest) -> Map {
"invocation_type".into(), "invocation_type".into(),
invocation_type_str(req.invocation_type).into(), invocation_type_str(req.invocation_type).into(),
); );
// Read-only depth of this call in a trigger/invoke chain: 0 for direct
// ingress, +1 per synchronous re-entry (`invoke`) or dispatched handler.
// Exposed so a script can observe where it sits in the chain (the same value
// the platform bounds via `trigger_depth_max`).
ctx.insert(
"trigger_depth".into(),
(i64::from(req.trigger_depth)).into(),
);
let mut request = Map::new(); let mut request = Map::new();
request.insert("path".into(), req.path.clone().into()); request.insert("path".into(), req.path.clone().into());

View File

@@ -194,16 +194,23 @@ async fn invoke_callee_sees_incremented_depth() {
#{ statusCode: 200, body: d } #{ statusCode: 200, body: d }
"# "#
.to_string(); .to_string();
// The caller is direct ingress (depth 0); the callee, re-entered via
// `invoke`, must see depth 1. Previously this test asserted NOTHING (`let _ =
// resp`) because `ctx.trigger_depth` wasn't exposed — so it would have passed
// even if `invoke` forwarded the depth unchanged (never incrementing the
// chain-depth counter that bounds runaway trigger loops). `ctx.trigger_depth`
// is now surfaced in build_ctx_map, so we can pin the increment.
let req = baseline_request(app); let req = baseline_request(app);
assert_eq!(req.trigger_depth, 0, "the caller is direct ingress");
let resp = tokio::task::spawn_blocking(move || engine.execute(&src, req)) let resp = tokio::task::spawn_blocking(move || engine.execute(&src, req))
.await .await
.unwrap() .unwrap()
.unwrap(); .unwrap();
// ctx exposes trigger_depth? — not yet (it's not in build_ctx_map). assert_eq!(
// Skip the strict assertion — the test still ensures the invoke resp.body,
// chain didn't throw. (See HANDBACK §11 for cx.trigger_depth surface json!(1),
// exposure as a v1.2 follow-up.) "the invoke callee must see trigger_depth = caller + 1"
let _ = resp; );
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]