From 513ddcd4d083eca65fbe616a3323d9e748aa5666 Mon Sep 17 00:00:00 2001 From: Masterplanner25 Date: Wed, 23 Sep 2026 21:34:33 -0700 Subject: [PATCH 1/2] workflow: rejecting a draft and replaying it with feedback works again (#870) resume_workflow(id, "before_draft", {"feedback": "..."}) on a waiting run replays from that checkpoint with the payload visible to workflow_resume_payload(), and parks at the wait again. Post-wait steps do NOT run, so a side-effecting publish cannot fire on a rejection. #482 (5.5.0) refused it, on the stated grounds that "the rollback re-armed the wait and the payload was silently discarded". The payload was not discarded. Measured on 4.0.8, where the spelling worked, it reached the replayed step; what it did not do was SATISFY THE WAIT, which is a different thing and is exactly what a reviewer rejecting a draft wants. The capability was never actually removed, only its spelling. The refusal's condition includes `event_type is None`, so passing that VERIFICATION argument to the Python runner has skipped the refusal the whole time -- an accident, not a design, and relying on it was the state of the art. Confirmed on main before changing anything: the event_type path replays with the payload visible, publish does not fire, the run re-parks. BothPathsAgree pins the two spellings to one answer so the supported one cannot drift back onto the accident. The two-phase workaround people found instead is worse and the guide now says so: two calls, and phase one runs every post-wait step, so each must gate its own side effects on the payload. resume_workflow(id, "checkpoint") with no payload is STILL refused, and that is what #482 earned -- a rollback re-enters the waiting step, which waits again, so nothing changes behind a healthy-looking result. Its message names both recipes now rather than only the advancing one. The neighbouring mistakes were already covered elsewhere and are unchanged: a checkpoint the run has not reached and a typo'd one both fail with "Checkpoint not found". test_checkpoint_with_payload_is_refused_naming_the_discard asserted the false premise, so it is rewritten rather than kept -- the same shape as #102's scan test in #869, which pinned that defect too. The 5.5.0 changelog entry is ANNOTATED, not rewritten: it is what that release shipped, and silently editing it would leave the repo disagreeing with the published notes. The note says which sentence is wrong and why it mattered -- it is the sentence a future reader would use to decide whether the refusal can be relaxed. nodus_gate --all green, all ten phases, before the push this time (#869's lesson). Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 35 ++++ docs/guide/real-world-integration.md | 45 ++++- src/nodus_lang_workflow/runner.py | 56 +++--- tests/test_reject_and_revise.py | 183 ++++++++++++++++++++ tests/test_waiting_run_checkpoint_resume.py | 37 ++-- 5 files changed, 317 insertions(+), 39 deletions(-) create mode 100644 tests/test_reject_and_revise.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 63217621..fbd03900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,33 @@ ### Fixes +- **#870: rejecting a draft and replaying it with feedback works again.** + `resume_workflow(id, "before_draft", {"feedback": "..."})` on a waiting run + replays from that checkpoint with the payload visible to + `workflow_resume_payload()`, and parks at the wait again. Post-wait steps do + **not** run, so a side-effecting `publish` cannot fire on a rejection. + + #482 (5.5.0) refused it, on the stated grounds that *"the rollback re-armed + the wait and the payload was silently discarded"*. The payload was not + discarded — measured on 4.0.8, where the spelling worked, it reached the + replayed step. What it did not do was **satisfy the wait**, which is a + different thing and is exactly what a reviewer rejecting a draft wants. The + 5.5.0 entry is annotated rather than rewritten, since it is what that release + shipped. + + The capability was never actually removed, only its spelling: the refusal's + condition includes `event_type is None`, so passing that **verification** + argument to the Python runner has skipped it the whole time. Relying on an + accident was the state of the art here, and the two-phase workaround people + found instead is worse — two calls, and it runs every post-wait step. + + **`resume_workflow(id, "checkpoint")` with no payload is still refused**, and + that refusal is what #482 earned: a rollback re-enters the waiting step, which + waits again, so nothing changes behind a healthy-looking result. Its message + now names both recipes rather than only the advancing one. A checkpoint the + run has not reached, and a typo'd one, already failed with `Checkpoint not + found` and still do. + - **#869: a run parked at `workflow_wait` no longer ages out of existence.** `LocalWorkflowStore` skipped any file whose mtime was older than `terminal_max_age_days` (default 30) *before opening it* — the only way to @@ -5035,6 +5062,14 @@ odus-a2a-wire` "the local worktree" of the wire repo. That directory's administratively over a graph that ran past the wait (a stale registration) still resumes and clears the mark. + > **Corrected by #870.** "The payload was silently discarded" is wrong, and + > the error message repeated it. The payload *did* reach + > `workflow_resume_payload()` in the replayed step; what it did not do was + > **satisfy the wait**. On that false premise the refusal also covered + > `checkpoint + payload`, which is reject-and-revise and works — so this + > entry describes removing a no-op and in fact removed a working pattern too. + > Left as written, with this note, because it is what 5.5.0 shipped. + - **#486: resuming from a mid-step checkpoint no longer double-counts folded state, and the re-entry rule is documented.** A resume re-enters the step that recorded the checkpoint from the top — that is the decided semantics, diff --git a/docs/guide/real-world-integration.md b/docs/guide/real-world-integration.md index e970a854..c3331a60 100644 --- a/docs/guide/real-world-integration.md +++ b/docs/guide/real-world-integration.md @@ -176,12 +176,45 @@ already written changes. Declaring no schema accepts anything, as before. > **A waiting run is advanced by a payload, not by a checkpoint.** > `resume_workflow(id, {payload})` satisfies the wait and the run moves on. -> `resume_workflow(id, "checkpoint")` on a waiting run is **refused** with an -> error naming the event the run is waiting on (#482) — a checkpoint rollback -> re-enters the waiting step from the top, which re-arms the wait, so it could -> only ever no-op (and with a payload alongside, discard it). Before the -> refusal existed, that call returned a healthy-looking `"status": "waiting"` -> result and silently did nothing. +> `resume_workflow(id, "checkpoint")` — a checkpoint *alone* — is **refused** +> (#482): a rollback re-enters the waiting step from the top, which re-arms the +> wait, so the call could only ever no-op. Before the refusal existed it +> returned a healthy-looking `"status": "waiting"` result and silently did +> nothing. + +### Rejecting a draft: replay with feedback + +The other thing a reviewer wants is not "advance" but **"go back and try +again"**. Pass the checkpoint *and* a payload: + +```python +resume_nd(graph_id, "before_draft", {"feedback": "too formal, try again"}) +``` + +The run replays from that checkpoint, the replayed step reads the feedback +through `workflow_resume_payload()`, and the flow parks at its wait again ready +for the next review. Post-wait steps do **not** run, so a side-effecting +`publish` cannot fire on a rejection. + +The two calls answer different questions, and the checkpoint argument is what +distinguishes them: + +| Call | Meaning | +|---|---| +| `resume_workflow(id, {...})` | approve: satisfy the wait, run the rest | +| `resume_workflow(id, "cp", {...})` | reject: replay from `cp` with feedback, park again | +| `resume_workflow(id, "cp")` | refused — nothing would change | + +A checkpoint the run has not reached yet, or a typo, fails with +`Checkpoint not found` rather than doing something surprising. + +> This was refused between 5.5.0 and 5.14.0 (#870). The refusal's stated reason +> was that the payload would be "silently discarded", which was not what +> happened — the payload reached the replayed step; what it did not do was +> satisfy the wait. If you are on one of those versions, the workaround is two +> calls: satisfy the wait with a payload your post-wait steps gate on, then roll +> back. Note that this runs every post-wait step, which is why it is a +> workaround and not the recipe. The `gate` step is a pure orchestration step — it does nothing except park the flow until the external signal arrives. The `execute` step reads the payload diff --git a/src/nodus_lang_workflow/runner.py b/src/nodus_lang_workflow/runner.py index 8fdd1ba9..8802c95a 100644 --- a/src/nodus_lang_workflow/runner.py +++ b/src/nodus_lang_workflow/runner.py @@ -1219,13 +1219,29 @@ def resume_workflow( # re-enters the waiting step, which hits its `workflow_wait` again -- # the run goes straight back to `waiting` and the result looks # healthy, so a caller checking for an error sees success while - # nothing happened. With a payload it is worse: the rollback re-arms - # the wait and the payload is silently discarded (a wait is a - # sentinel the engine pauses on; nothing consults the pending payload - # when a re-run step re-arms it). The call that advances a waiting - # run delivers a payload *without* a checkpoint. Refuse both no-op - # combinations with the real reason, the way #399 and #425 replaced - # misleading answers on this same path. + # nothing happened. That is a real no-op and is still refused. + # + # #870: but the same refusal covered `checkpoint` **with** a payload, + # on the stated grounds that "the payload is silently discarded". It + # is not, and was not: the payload reaches + # `workflow_resume_payload()` in the replayed step. What it does not + # do is *satisfy the wait* -- the replay re-arms it -- which is a + # different thing and is exactly what a reviewer rejecting a draft + # wants: replay from before the draft with the feedback, then park + # for approval again. + # + # Measured on 4.0.8, where the spelling worked, and on 5.14.0 through + # `event_type=` (which skips this branch and always has), the run + # replays with the payload visible, post-wait steps do NOT fire, and + # the run re-parks. So #482 removed a spelling and left the + # capability reachable only by passing a *verification* argument -- + # an accident, not a design. The two-phase workaround people found + # instead is worse: it takes two calls and runs every post-wait step, + # so a side-effecting step fires on a rejection unless it gates. + # + # The neighbouring mistakes are already refused elsewhere and do not + # need this branch: a checkpoint the run has not reached, and a + # typo'd one, both fail with "Checkpoint not found". # # "Genuinely" is the persisted graph state agreeing the run is # waiting. A record marked waiting administratively @@ -1234,6 +1250,7 @@ def resume_workflow( # doing so. if ( checkpoint is not None + and resume_payload is None and event_type is None and record is not None and record.status == RUN_STATUS_WAITING @@ -1241,22 +1258,15 @@ def resume_workflow( and state.get("status") == "waiting" ): wait_event = record.wait.event_type if record.wait is not None else None - if resume_payload is None: - detail = ( - f"pass a payload to satisfy it -- " - f"resume_workflow(graph_id, {{...}}) -- and the run " - f"will advance. Resuming from checkpoint " - f"'{checkpoint}' alone re-enters the waiting step, " - f"which waits again." - ) - else: - detail = ( - f"drop the checkpoint argument -- " - f"resume_workflow(graph_id, {{...}}) -- and the payload " - f"will satisfy it. Rolling back to checkpoint " - f"'{checkpoint}' re-enters the waiting step, which " - f"re-arms the wait and discards the payload." - ) + detail = ( + f"pass a payload to satisfy it -- " + f"resume_workflow(graph_id, {{...}}) -- and the run " + f"will advance. Resuming from checkpoint " + f"'{checkpoint}' alone re-enters the waiting step, " + f"which waits again. To reject and replay instead, pass " + f"the feedback with the checkpoint -- " + f"resume_workflow(graph_id, '{checkpoint}', {{...}})." + ) return { "ok": False, "error": ( diff --git a/tests/test_reject_and_revise.py b/tests/test_reject_and_revise.py new file mode 100644 index 00000000..9ee00274 --- /dev/null +++ b/tests/test_reject_and_revise.py @@ -0,0 +1,183 @@ +"""Rejecting a draft and replaying it with feedback (#870). + +A reviewer looking at a parked run wants one of two things, and the checkpoint +argument is what distinguishes them: + +| Call | Meaning | +|---|---| +| `resume_workflow(id, {...})` | approve: satisfy the wait, run the rest | +| `resume_workflow(id, "cp", {...})` | reject: replay from `cp` with feedback, park again | +| `resume_workflow(id, "cp")` | refused — nothing would change | + +#482 (5.5.0) refused the middle row along with the last, because it believed the +payload was "silently discarded" by the rollback. It was not: measured on 4.0.8, +where the spelling worked, the payload reached `workflow_resume_payload()` in the +replayed step. What it did not do was *satisfy the wait* — which is the whole +point of a rejection. + +The capability was never removed, only its spelling. The refusal's condition +includes `event_type is None`, so passing that **verification** argument to the +Python runner skipped it the whole time. These tests pin the two paths to the +same answer so the `.nd` spelling cannot drift back into relying on an accident. + +The last class is the one that keeps #482: a checkpoint with no payload really +is a no-op and must stay refused. +""" + +import sys +import unittest + +sys.path.insert(0, "C:/dev/Coding Language/src") + +from nodus.runtime.embedding import NodusRuntime # noqa: E402 + +SOURCE = ''' +workflow w { + step draft { + checkpoint "before_draft" + let p = workflow_resume_payload() + return {"fb": str(p)} + } + step gate after draft { return workflow_wait("approve", "k1", {}) } + step publish after gate { + checkpoint "after_wait" + return {"published": true} + } +} +''' + + +class _Harness(unittest.TestCase): + def setUp(self): + self.captured: dict = {} + + def _runtime(self): + runtime = NodusRuntime(timeout_ms=None) + runtime.register_function( + "_cap", lambda v: self.captured.__setitem__("r", v), arity=1) + return runtime + + def _parked_run(self): + runtime = self._runtime() + runtime.run_source(SOURCE + "\n_cap(run_workflow(w))\n") + started = runtime._to_host_value(self.captured["r"]) + self.assertEqual(started["status"], "waiting", "expected a parked run") + return started["graph_id"] + + def _resume(self, graph_id, checkpoint, payload): + runtime = self._runtime() + runtime.run_source(SOURCE) + vm = runtime._get_active_vm() + return runtime._to_host_value( + vm.builtin_resume_workflow(graph_id, checkpoint, payload)) + + +# closes: #870 +class RejectAndRevise(_Harness): + def test_a_checkpoint_with_feedback_replays_and_parks_again(self): + result = self._resume(self._parked_run(), "before_draft", {"feedback": "redo"}) + + self.assertIsNot( + result.get("ok"), False, + f"reject-and-revise was refused: {result.get('error')}", + ) + self.assertEqual(result["status"], "waiting", "the run did not park again") + + def test_the_replayed_step_reads_the_feedback(self): + result = self._resume(self._parked_run(), "before_draft", {"feedback": "redo"}) + self.assertIn( + "redo", result["steps"]["draft"]["fb"], + "the payload did not reach workflow_resume_payload() in the replayed " + "step — which is the claim #482 was refused on", + ) + + def test_post_wait_steps_do_not_fire_on_a_rejection(self): + # The reason this beats the two-phase workaround. Satisfying the wait + # first runs `publish`, so a side-effecting step fires on a rejection + # unless it gates on the payload itself. + result = self._resume(self._parked_run(), "before_draft", {"feedback": "redo"}) + self.assertNotIn( + "publish", {k for k, v in result["steps"].items() if v is not None}, + "a post-wait step ran during a rejection", + ) + + def test_a_rejected_run_can_then_be_approved(self): + # The cycle a reviewer actually performs, not just one leg of it. + graph_id = self._parked_run() + self._resume(graph_id, "before_draft", {"feedback": "redo"}) + approved = self._resume(graph_id, None, {"approved": True}) + self.assertEqual(approved["steps"]["publish"], {"published": True}) + + +# closes: #870 +class TheRefusalThatStays(_Harness): + def test_a_checkpoint_with_no_payload_is_still_refused(self): + result = self._resume(self._parked_run(), "before_draft", None) + self.assertIs(result.get("ok"), False) + self.assertEqual(result.get("category"), "waiting_run_checkpoint_resume") + + def test_the_refusal_names_both_recipes(self): + # It used to name only the advancing one, which is the opposite of what + # a rejecting reviewer wants. + result = self._resume(self._parked_run(), "before_draft", None) + message = str(result.get("error")) + self.assertIn("pass a payload to satisfy it", message) + self.assertIn("reject and replay", message) + + def test_the_refusal_no_longer_claims_the_payload_is_discarded(self): + # The false premise itself. It was in the message as well as the + # changelog, so a reader hit it twice. + result = self._resume(self._parked_run(), "before_draft", None) + self.assertNotIn("discards the payload", str(result.get("error"))) + + def test_a_checkpoint_the_run_has_not_reached_is_refused(self): + result = self._resume(self._parked_run(), "after_wait", {"feedback": "x"}) + self.assertIs(result.get("ok"), False) + self.assertIn("Checkpoint not found", str(result.get("error"))) + + def test_a_typo_is_refused(self): + result = self._resume(self._parked_run(), "no_such_checkpoint", {"feedback": "x"}) + self.assertIs(result.get("ok"), False) + self.assertIn("Checkpoint not found", str(result.get("error"))) + + def test_a_payload_alone_still_advances_the_run(self): + # The control. Without it every test above passes on a build where + # resume is broken in general. + result = self._resume(self._parked_run(), None, {"approved": True}) + self.assertEqual(result["steps"]["publish"], {"published": True}) + + +# closes: #870 +class BothPathsAgree(_Harness): + """The `.nd` spelling and the accidental `event_type=` escape hatch. + + `event_type` is a *verification* argument — "refuse unless the run is waiting + on this event". It suppressed the #482 refusal only because the condition + included `event_type is None`. Pinning the two to the same answer is what + stops the supported spelling drifting back into depending on that. + """ + + def _resume_via_runner(self, graph_id, checkpoint, payload): + runtime = self._runtime() + runtime.run_source(SOURCE) + vm = runtime._get_active_vm() + return runtime._to_host_value(vm.resolve_workflow_runner().resume_workflow( + vm, graph_id, checkpoint, + resume_payload=payload, + event_type="approve", + rebuild_graph=vm._rebuild_workflow_graph, + )) + + def test_the_nd_builtin_matches_the_event_type_path(self): + through_builtin = self._resume( + self._parked_run(), "before_draft", {"feedback": "redo"}) + through_runner = self._resume_via_runner( + self._parked_run(), "before_draft", {"feedback": "redo"}) + + self.assertEqual(through_builtin["status"], through_runner["status"]) + self.assertEqual( + through_builtin["steps"]["draft"], through_runner["steps"]["draft"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_waiting_run_checkpoint_resume.py b/tests/test_waiting_run_checkpoint_resume.py index 86a9d8bb..2c52f86c 100644 --- a/tests/test_waiting_run_checkpoint_resume.py +++ b/tests/test_waiting_run_checkpoint_resume.py @@ -3,12 +3,19 @@ `resume_workflow(id, "checkpoint")` on a waiting run used to re-enter the waiting step, which hit its `workflow_wait` again -- the run went straight back to `waiting` behind a healthy-looking result map (`ok` not false, nothing in -`failed`, one more duplicate checkpoint entry as the only trace). With a -payload alongside the checkpoint it was worse: the rollback re-armed the wait -and the payload was silently discarded. +`failed`, one more duplicate checkpoint entry as the only trace). -Both combinations are refused now, naming the event the run is waiting on and -the call that advances it. "Genuinely waiting" means the persisted graph state +That call — a checkpoint with **no payload** — is refused now, naming the event +the run is waiting on and both the calls that do something. + +**#482 also refused `checkpoint + payload`, and #870 undid that.** Its stated +reason was that the rollback "silently discarded" the payload; it does not. The +payload reaches `workflow_resume_payload()` in the replayed step, and what it +does not do is *satisfy the wait* — which is precisely what rejecting a draft +means. `test_reject_and_revise.py` covers that behaviour; this file keeps the +refusal that earned its place. + +"Genuinely waiting" means the persisted graph state agrees -- a record marked waiting administratively over a graph that ran past the wait (a stale registration) still resumes and clears the mark, which `test_nodus_workflow_framework.py::test_resume_clears_wait_registration` pins. @@ -78,12 +85,22 @@ def test_checkpoint_only_resume_is_refused_with_the_real_reason(self): # duplicated checkpoint entry was the trace of each no-op attempt. self.assertNotIn("asking human...", stdout) - def test_checkpoint_with_payload_is_refused_naming_the_discard(self): + # closes: #870 + def test_checkpoint_with_payload_replays_instead_of_being_refused(self): + """This asserted the refusal until #870, on a false premise. + + #482 refused `checkpoint + payload` because it believed the rollback + discarded the payload. It did not — the payload reaches the replayed + step; what it does not do is *satisfy the wait*. That is + reject-and-revise, and refusing it removed a working pattern. + `test_reject_and_revise.py` covers the behaviour in full. + """ stdout = self._resume('"started", {"reply": "eaten"}') - self.assertIn('"ok": false', stdout) - self.assertIn("discards the payload", stdout) - self.assertIn("drop the checkpoint argument", stdout) - self.assertNotIn("asking human...", stdout) + self.assertNotIn('"ok": false', stdout) + self.assertIn('"status": "waiting"', stdout) + # The replay really re-enters the step, so the pre-wait effect fires — + # unlike the refused case below, where it must not. + self.assertIn("asking human...", stdout) def test_payload_resume_still_advances_the_run(self): """Falsifiability control: the refusal must not catch the call that From 8f383939f3d414cf55c923bcbf3c62384bdc96dd Mon Sep 17 00:00:00 2001 From: Masterplanner25 Date: Wed, 23 Sep 2026 21:48:11 -0700 Subject: [PATCH 2/2] probes: the release probe pinned the false premise as a string (#870) CI's `probes` job failed on `5.4.0: a checkpoint resume of a waiting run is refused`. It is a source assertion, and one of its three assertions required the literal string "discards the payload" to be PRESENT in `resume_workflow` -- the sentence #870 removed because it is false. So the premise was pinned in three places at once: the changelog, the error message, and a release probe asserting the error message. The probe now asserts what the refusal actually guarantees: still refuses, still consults the persisted state, is scoped by `and resume_payload is None`, and no longer claims the payload is discarded. Added a BEHAVIOURAL probe beside it. A source assertion cannot tell you the capability works, which is exactly how the false premise survived -- it was asserted as a string rather than run. The new one parks a run, rejects with feedback, checks the replayed step saw it and the run re-parked, then approves. Both were falsified against the restored 5.5.0 refusal before being kept: the source probe fails on the scoping assertion, the behavioural one on the reject itself. Two process notes, because both nearly cost a second red CI: - `nodus_gate --all` does NOT run the probes. I ran the gate before pushing (#869's lesson) and still missed this, because the probes are a separate harness that CI runs as its own job. Run both. - The probe count did not move when I added the probe -- 129/129. `main()` invokes probes from an explicit list, so a decorated function nobody calls is dead code that looks live. That is #794's shape (declared, printed, read by nothing). Adding the call took it to 130/130, and the moving count is the only thing that showed it. Also: `@probe` swallows AssertionError into RESULTS, so calling a probe and seeing no exception proves nothing. Read RESULTS. Co-Authored-By: Claude Opus 5 (1M context) --- tests/eval/release_claims_probe.py | 61 ++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/tests/eval/release_claims_probe.py b/tests/eval/release_claims_probe.py index 0c32a5ee..00d7a857 100644 --- a/tests/eval/release_claims_probe.py +++ b/tests/eval/release_claims_probe.py @@ -734,18 +734,72 @@ def probe_topology_validation(): return "matching shape passes; a drifted one names the real cause, not a cycle" -@probe("5.4.0: a checkpoint resume of a waiting run is refused") +@probe("5.4.0: a checkpoint resume of a waiting run is refused when it would no-op") def probe_waiting_resume_refused(): from nodus_lang_workflow import runner as runner_module source = inspect_source(runner_module.WorkflowFrameworkRunner.resume_workflow) assert "waiting_run_checkpoint_resume" in source, "the refusal is gone" - assert "discards the payload" in source, "the payload-eating case is unguarded" assert 'state.get("status") == "waiting"' in source, ( "the refusal no longer consults the persisted state, so a stale " "administrative wait would be refused" ) - return "refused on genuinely-waiting runs only, both argument shapes" + # #870: the refusal is scoped to the argument shape that really does + # nothing. It used to cover `checkpoint + payload` too, on the stated + # grounds that the rollback "discards the payload" -- which it does not: the + # payload reaches the replayed step, it simply does not satisfy the wait. + # This probe asserted that false sentence was PRESENT in the source, so the + # premise was pinned in three places at once (changelog, error message, + # here). + assert "and resume_payload is None" in source, ( + "the refusal is not scoped to the no-payload case, so reject-and-revise " + "is blocked again (#870)" + ) + assert "discards the payload" not in source, ( + "the refusal still claims the rollback discards the payload; it reaches " + "the replayed step (#870)" + ) + return "refused only with no payload; checkpoint+payload replays (#870)" + + +@probe("5.15.0: rejecting a draft replays it with the feedback (#870)") +def probe_reject_and_revise(): + # The behavioural half. The probe above reads the source, and a source + # assertion cannot tell you the capability works -- which is how the false + # "discards the payload" premise survived: it was asserted as a *string*. + source = ( + "workflow w {\n" + ' step draft { checkpoint "before_draft"; ' + "let p = workflow_resume_payload(); return {\"fb\": str(p)} }\n" + ' step gate after draft { return workflow_wait("approve", "k1", {}) }\n' + ' step publish after gate { return {"published": true} }\n' + "}\n" + "fn main() {\n" + " let r = run_workflow(w)\n" + " let g = r[\"graph_id\"]\n" + ' let back = resume_workflow(g, "before_draft", {"feedback": "redo"})\n' + ' let st = back["status"]\n' + ' let steps = back["steps"]\n' + ' let d = steps["draft"]\n' + ' print("STATUS=\\(st) DRAFT=\\(d)")\n' + ' let done = resume_workflow(g, {"approved": true})\n' + ' let ds = done["steps"]\n' + ' let pub = ds["publish"]\n' + ' print("PUB=\\(pub)")\n' + "}" + ) + out = run_nd(source) + assert out.get("ok"), f"reject-and-revise failed: {out.get('error')}" + text = out.get("stdout") or "" + assert "STATUS=waiting" in text, f"the run did not park again: {text[:200]}" + assert "redo" in text, ( + f"the replayed step did not see the feedback -- the exact claim #482 " + f"was refused on: {text[:200]}" + ) + assert "PUB=" in text and "published" in text, ( + f"the run could not be approved after a rejection: {text[:200]}" + ) + return "replays with the feedback, parks again, then approves" @probe("5.4.0: a persist failure names the cell, and durable:false protects it") @@ -3243,6 +3297,7 @@ def main() -> int: probe_graph_does_not_execute(args.repo) probe_topology_validation() probe_waiting_resume_refused() + probe_reject_and_revise() probe_persist_naming() probe_goal_waypoint() probe_check_enters_steps()