Skip to content

Commit 4bd77cb

Browse files
fahchenclaude
andcommitted
fix(dsl): derive action CPN vars from incoming arcs (ground truth)
The previous compile_transition_action/2 inferred which keys to `Keyword.fetch!/2` out of `event.binding` from `ExpressionHelper.free_vars/1` on the action body. That over-approximates: any locally-introduced name inside the body (a literal `pid = options[:test_pid]` or a pattern-match temporary inside an `if`/`case`) was treated as a CPN var, so `Keyword.fetch!(event.binding, :pid)` raised `KeyError`, the spawned `Task` crashed, and `LifecycleHooks.safe_invoke/3` swallowed it — the action silently never ran. Switch the injection set to ground truth: at `__close_transition__!` time we already know which CPN variables this transition's incoming arcs bind. Use that list, drop the body-free-var step from the action macro entirely, and let the body's pattern-match locals stay local. To keep the compiler quiet under `--warnings-as-errors` when the body only references a subset of the bound vars, every fetched binding gets a paired `_used = var` discard. The injection still happens uniformly (simpler reasoning, matches "all CPN vars are available") but unused vars never trigger the unused-variable lint. Drive-by: rename the misleading `describe "lifecycle dispatch (bare module hooks)"` block to `"lifecycle dispatch (hooks options carry test_pid only)"` — the test does not exercise the bare-module path (that's covered by `LifecycleHooks.validate!/1` unit tests). Revert the two-step `pid = options[:test_pid]; if pid do` workaround in the e2e test back to the natural inline `if pid = options[:test_pid] do …` form now that local pattern-match names no longer collide with CPN-var injection. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 1dcd405 commit 4bd77cb

5 files changed

Lines changed: 109 additions & 31 deletions

File tree

docs/tmp/pr-comments.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# PR #89 — Unresolved Comments
2+
3+
Latest commit: `5475998`. 7 unresolved (5 stale copilot + 2 new fahchen).
4+
5+
## Open
6+
7+
### 1. `test/coloured_flow/dsl/listener_e2e_test.exs` (file-level) — @copilot
8+
> setup_flow!/0 not idempotent vs PR description.
9+
10+
Thread: `PRRT_kwDOMZc9dM5_KOFJ` / Comment: `3177549638`
11+
**Status:** keep **Decision:** stale — file renamed to `lifecycle_hooks_e2e_test.exs` and `setup_flow!` removed entirely in `5475998` (Q1).
12+
13+
---
14+
15+
### 2. `lib/coloured_flow/dsl/spec.md` (file-level) — @copilot
16+
> ActionHandler vs Listener naming mismatch.
17+
18+
Thread: `PRRT_kwDOMZc9dM5_KOFW` / Comment: `3177549653`
19+
**Status:** keep **Decision:** stale — module renamed `ActionHandler → Listener → LifecycleHooks` (final). 7 callbacks (`on_workitem_reoffered` dropped). spec.md aligned in `5475998`. PR title/description need manual update.
20+
21+
---
22+
23+
### 3. `lib/coloured_flow/dsl/builder.ex` (file-level) — @copilot
24+
> setup_flow!/0 doc says raise on missing name but impl doesn't validate.
25+
26+
Thread: `PRRT_kwDOMZc9dM5_KOFh` / Comment: `3177549664`
27+
**Status:** keep **Decision:** stale — `setup_flow!/0` removed entirely in `5475998`. DSL no longer inserts flows.
28+
29+
---
30+
31+
### 4. `lib/coloured_flow/runner/enactment/enactment.ex` #310@copilot
32+
> apply_calibration dispatches workitem callbacks before on_enactment_start during boot.
33+
34+
Thread: `PRRT_kwDOMZc9dM5_KOFo` / Comment: `3177549671`
35+
**Status:** keep **Decision:** principle is "each callback fires after its own telemetry event"; current code obeys that. Cross-callback ordering (workitem_enabled before enactment_start) is acceptable because each callback follows its own emit point.
36+
37+
---
38+
39+
### 5. `lib/coloured_flow/dsl/storage.ex` (file-level) — @copilot
40+
> Idempotency claim mismatch.
41+
42+
Thread: `PRRT_kwDOMZc9dM5_KOFq` / Comment: `3177549673`
43+
**Status:** keep **Decision:** stale — file `lib/coloured_flow/dsl/storage.ex` deleted entirely in `5475998` (Q1).
44+
45+
---
46+
47+
### 6. `lib/coloured_flow/runner/enactment/enactment.ex` (file-level) — @fahchen
48+
> 这个文件太大了,可以把新增的部分抽象出来
49+
50+
Thread: `PRRT_kwDOMZc9dM5_Kedo` / Comment: `3177634901`
51+
**Status:** fix **Decision:** extract `dispatch_lifecycle/3` + `dispatch_post_calibration/3` into new module `ColouredFlow.Runner.Enactment.LifecycleHooks.Dispatcher`. API takes `%Enactment{}` state struct directly (internal helper, coupling acceptable). `:abnormal_exit` line stays in `terminate/2`.
52+
53+
---
54+
55+
### 7. `test/coloured_flow/dsl/lifecycle_hooks_e2e_test.exs` #37@fahchen
56+
> 通过 options 传进来,而不是 persistent term
57+
58+
Thread: `PRRT_kwDOMZc9dM5_KfCI` / Comment: `3177637783`
59+
**Status:** fix **Decision:** drop `:persistent_term` test-pid hack. Pass `test_pid` via `lifecycle_hooks: {Workflow, [test_pid: self()]}` options. DSL bodies read `options[:test_pid]` and send. Apply same pattern to `lifecycle_hooks_dispatch_test.exs` (also uses persistent_term).
60+
61+
---

lib/coloured_flow/dsl/builder.ex

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -383,20 +383,43 @@ defmodule ColouredFlow.DSL.Builder do
383383
# * `event` — `LifecycleHooks.workitem_completed_event()` map carrying
384384
# `:enactment_id`, `:markings`, `:workitem`, `:occurrence`, `:binding`.
385385
# * `options` — keyword list registered alongside the hook module.
386-
# * each free CPN variable (`:s`, `:x`, …) — plucked from `event.binding`.
386+
# * each CPN variable bound by the transition's incoming arcs (`:s`,
387+
# `:x`, …) — plucked from `event.binding`. Body-local pattern-match
388+
# names are NOT injected, so writing `pid = options[:pid]` (or any
389+
# local) inside `action do ... end` is safe.
387390
#
388-
# The wrapper unpacks `event.binding` via `Keyword.fetch!/2` for each declared
389-
# free var and runs the body inside a `Task.Supervisor.start_child/2` call
390-
# (or unsupervised `Task.start/1` when no `:task_supervisor` was provided to
391+
# The wrapper unpacks `event.binding` via `Keyword.fetch!/2` for each
392+
# transition-bound var (using a `_`-prefixed alias when the body does not
393+
# reference the var, to suppress unused-variable warnings) and runs the
394+
# body inside a `Task.Supervisor.start_child/2` call (or unsupervised
395+
# `Task.start/1` when no `:task_supervisor` was provided to
391396
# `use ColouredFlow.DSL`) so the runner never blocks on user side effects.
392-
defp compile_transition_action({transition_name, body, cpn_vars}, task_supervisor) do
397+
defp compile_transition_action(
398+
{transition_name, body, incoming_vars},
399+
task_supervisor
400+
) do
401+
# `incoming_vars` is the ground-truth set of CPN variables this
402+
# transition's incoming arcs bind — we always fetch each one from
403+
# `event.binding` and immediately discard the binding via `_ = var`
404+
# so a body that only references a subset of the bound vars does
405+
# not trip `--warnings-as-errors`. Body-local pattern-match names
406+
# (`pid = options[:pid]`, etc.) are NOT in this list and are never
407+
# touched.
393408
var_assignments =
394-
Enum.map(cpn_vars, fn var ->
409+
Enum.flat_map(incoming_vars, fn var ->
395410
var_ast = Macro.var(var, nil)
396411

397-
quote do
398-
unquote(var_ast) = Keyword.fetch!(var!(event).binding, unquote(var))
399-
end
412+
[
413+
quote do
414+
unquote(var_ast) = Keyword.fetch!(var!(event).binding, unquote(var))
415+
end,
416+
quote do
417+
# Discard expression so a body that doesn't reference the var
418+
# doesn't trip `--warnings-as-errors`. The underscore prefix
419+
# satisfies the project's unused-variable naming convention.
420+
_used = unquote(var_ast)
421+
end
422+
]
400423
end)
401424

402425
task_call = wrap_in_task(body, task_supervisor)

lib/coloured_flow/dsl/transition.ex

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,6 @@ defmodule ColouredFlow.DSL.Transition do
1717

1818
@scope_attr :__cf_transition_scope__
1919

20-
# Magic bindings made available inside an `action do ... end` body, in
21-
# addition to any free CPN variables resolved from the transition's binding.
22-
@action_magic_bindings [:event, :options]
23-
2420
@doc """
2521
Declare a transition. The block accepts `guard/1`, `action/1`, `input/2,3` and
2622
`output/2,3`.
@@ -118,19 +114,12 @@ defmodule ColouredFlow.DSL.Transition do
118114
caller_line = __CALLER__.line
119115
expr_ast = ExpressionHelper.block_to_ast(expression)
120116
code = ExpressionHelper.ast_to_code(expr_ast)
121-
122-
raw_free = ExpressionHelper.free_vars(expr_ast)
123-
cpn_vars = raw_free -- @action_magic_bindings
124117
escaped_ast = Macro.escape(expr_ast)
125118

126119
quote do
127120
ColouredFlow.DSL.Transition.__set_action__!(
128121
__MODULE__,
129-
%{
130-
code: unquote(code),
131-
body: unquote(escaped_ast),
132-
cpn_vars: unquote(cpn_vars)
133-
},
122+
%{code: unquote(code), body: unquote(escaped_ast)},
134123
unquote(caller_file),
135124
unquote(caller_line)
136125
)
@@ -183,11 +172,22 @@ defmodule ColouredFlow.DSL.Transition do
183172
Module.put_attribute(module, :cf_transitions, transition)
184173

185174
case scope.action do
186-
%{body: body, cpn_vars: cpn_vars} ->
175+
%{body: body} ->
176+
# Ground truth: the variables that this transition's incoming arcs
177+
# actually bind. The injection set comes from this list (not from
178+
# body free-var inference), so locally-introduced names
179+
# (`pid = options[:test_pid]`, match-pattern temporaries inside
180+
# the body) never get `Keyword.fetch!`-ed out of `event.binding`.
181+
incoming_vars =
182+
arcs
183+
|> Stream.filter(&match?(%Arc{orientation: :p_to_t}, &1))
184+
|> Stream.flat_map(& &1.expression.vars)
185+
|> Enum.uniq()
186+
187187
Module.put_attribute(
188188
module,
189189
:cf_transition_actions,
190-
{scope.name, body, cpn_vars}
190+
{scope.name, body, incoming_vars}
191191
)
192192

193193
_other ->

test/coloured_flow/dsl/lifecycle_hooks_e2e_test.exs

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,7 @@ defmodule ColouredFlow.DSL.LifecycleHooksE2ETest do
2424
output :output, {1, x}
2525

2626
action do
27-
# NOTE: bind in two steps. The action macro's free-var analysis treats
28-
# an inline `if pid = options[...] do ... end` as a CPN var (`:pid`)
29-
# and tries to fetch it from `event.binding`, which crashes the Task
30-
# silently. A separate assignment statement avoids that.
31-
pid = options[:test_pid]
32-
33-
if pid do
27+
if pid = options[:test_pid] do
3428
send(pid, {:action_fired, x, event.enactment_id, event.workitem.id, options})
3529
end
3630
end

test/coloured_flow/runner/enactment/lifecycle_hooks_dispatch_test.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ defmodule ColouredFlow.Runner.Enactment.LifecycleHooksDispatchTest do
6363
%{cpnet: cpnet}
6464
end
6565

66-
describe "lifecycle dispatch (bare module hooks)" do
66+
describe "lifecycle dispatch (hooks options carry test_pid only)" do
6767
setup :setup_flow
6868
setup :setup_enactment
6969

0 commit comments

Comments
 (0)