From 48d6b2f84bbeda56d4f181cbfe1e3049089e1810 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Wed, 29 Jul 2026 17:50:22 -0700 Subject: [PATCH] feat(coordinator): the nudge bodies state observed facts, never hedges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The idle-children header drops its opening idleness claim: a queued entry delivers at whichever seam arrives next, and the drain predicate re-verifies that children are active — never that the coordinator is still idle — so the body now opens with the one fact the delivery just verified. The tasks body replaces its hedged children sentence with one observed-fact line per child. The old sentence hedged states the producer's read had just returned and invented activity for an idle coordinator; the producer now threads (ws_id, state) pairs through, and the formatter renders a running child as running (check before redoing what it owns) and a stopped one as stopped, with the tool-behaviour fact that wait_for_workstream returns immediately for it. The line asserts nothing about results: no read observes whether a child produced anything, and the immediate wait is the whole protection — checking is cheap and finds whatever is there. Fact lines are formatter-built beside the counts opener, so no tail override can reach them; the formatter's old indeterminate-read hedge branch is deleted (a failed read renders no body at all), and the open-row status takes the same alteration check as the id. Both bodies hand the model full workstream ids: the resolver refuses truncated ids by design, so the roster's 8-char prefixes were not handles — a model copying a bullet issued a call the resolver rejects. Display prefixing stays on the operator card, derived from the full id in the metadata. Eval alignment: fixture child ids become production-shaped 32-hex (a prefix looked like a different id entirely and the old shape only resolved through the legacy branch); a body-override sweep refuses cells without a live child at config time, keyed on the formatter's own childless condition, so candidate text can never be measured over a world production cannot produce. --- tests/test_coordinator_idle_observer.py | 167 +++--- tests/test_eval_nudges.py | 376 ++++++++----- tests/test_idle_nudge_wake_integration.py | 34 +- tests/test_metacognition.py | 342 ++++++++---- .../console/coordinator_idle_observer.py | 174 +++--- .../console/static/coordinator/coordinator.js | 9 +- turnstone/core/metacognition.py | 508 +++++++++++------- turnstone/eval/cli.py | 15 +- turnstone/eval/nudges.py | 274 ++++++---- turnstone/eval/scenarios/nudges.py | 56 +- 10 files changed, 1197 insertions(+), 758 deletions(-) diff --git a/tests/test_coordinator_idle_observer.py b/tests/test_coordinator_idle_observer.py index 8b407dbe..21c505c7 100644 --- a/tests/test_coordinator_idle_observer.py +++ b/tests/test_coordinator_idle_observer.py @@ -25,8 +25,8 @@ from turnstone.console.coordinator_idle_observer import ( CoordinatorIdleObserver, ) from turnstone.core.metacognition import ( + NUDGE_CHILD_STOPPED_STATES, NUDGE_IDLE_TASKS_CHILD_SLOT, - NUDGE_IDLE_TASKS_CHILDREN_CAVEAT, NUDGE_IDLE_TASKS_ID_SLOT, NUDGE_IDLE_TASKS_WAIT_SLOT, NUDGE_REQUIRED_TOOL, @@ -350,14 +350,14 @@ class TestEnqueueOnIdle: # Same set, same order, same count — one list underneath. assert [r["ws_id"] for r in rows] == ["child-a", "child-b", "child-c-longer-than-8"] - # The model-facing body: id-prefix + state bullets, byte-exact, + # The model-facing body: FULL id + state bullets, byte-exact, # so no fragment of any name — hostile or benign — can ride # along, and no ``(unnamed)`` fallback exists to stand in. bullets = [ln for ln in text.split(chr(10)) if ln.startswith(" - ")] assert bullets == [ " - child-a (running)", " - child-b (thinking)", - " - child-c- (running)", + " - child-c-longer-than-8 (running)", ] assert "thinking>" not in text # the tag, never the state word assert "200ms" not in text @@ -370,13 +370,15 @@ class TestEnqueueOnIdle: for row in rows: assert set(row) == {"ws_id", "state"} - # IDENTITY. The meta keeps ``ws_id`` raw on every row, and the - # FE derives its ident column from it (``String(c.ws_id).slice(0, - # 8)`` in ``appendIdleChildren``, pinned in test_coordinator_page), - # matching the 8-char prefix the body's own bullet renders. + # IDENTITY. The meta keeps ``ws_id`` raw on every row; the body + # bullet carries the same full id (a HANDLE — the resolver + # refuses prefixes), and the FE derives its 8-char display ident + # from the meta (``String(c.ws_id).slice(0, 8)`` in + # ``appendIdleChildren``, pinned in test_coordinator_page) — + # formatting over one value, not a second value. assert all(r["ws_id"] for r in rows) - assert rows[2]["ws_id"][:8] == "child-c-" - assert "child-c-" in bullets[2] + assert rows[2]["ws_id"] == "child-c-longer-than-8" + assert bullets[2] == " - child-c-longer-than-8 (running)" def test_idle_with_no_active_children_no_enqueue(self, coord_setup): mgr, storage, ws = coord_setup @@ -773,12 +775,11 @@ class TestIdleTasks: The load-bearing case is ``test_active_children_co_deliver_with_ idle_tasks``: the classes are independent conditions, so when both hold the pair CO-DELIVERS in one drain, tasks first. Each body - asserts only its own domain — the tasks body carries the "children - may still be running" caveat whenever a live child row exists, and - says nothing about children when none does — so a consistent pair - is two true statements. The thing that must never happen is a - STALE pair, which the per-path reads and per-entry predicates - prevent. + asserts only its own domain — the tasks body renders one observed + fact line per live child row, and says nothing about children when + none exists — so a consistent pair is two true statements. The + thing that must never happen is a STALE pair, which the per-path + reads and per-entry predicates prevent. """ def test_open_tasks_and_no_children_enqueues(self, coord_setup): @@ -1376,14 +1377,18 @@ class TestIdleTasksMetadata: assert meta["counts"] == {"open": 1, "in_progress": 0, "pending": 1} -class TestTasksBodyChildrenCaveat: +class TestTasksBodyChildrenFacts: """Which BODY the tasks path enqueues, by observed children state. - The caveat is the one sentence in the tasks body that speaks about - the other domain. It is conditioned on EXISTENCE — any child row in - a live state — and not on the liveness path's ACTIVE set, because an - idle child holding results nobody collected is exactly the "may have - finished while you worked" disjunct the sentence carries. + The per-child fact lines are the tasks body's one statement about + the other domain, and they render the OBSERVED ``(ws_id, state)`` + the read returned — never a hedge about it (the retired caveat's + "may still be running or may have finished while you worked" was + manufactured uncertainty plus manufactured context, ruled out + 2026-07-29). They are conditioned on EXISTENCE — any child row in + a live state — and not on the liveness path's ACTIVE set, because + an idle child holding results nobody collected is exactly the row + the stopped-child line protects. Everything here is about CONTENT. That the fire itself is untouched by any ANSWER these states return is @@ -1406,7 +1411,7 @@ class TestTasksBodyChildrenCaveat: pending = ws.session._nudge_queue.pending_with_metadata("any") return next(entry for entry in pending if entry[0] == "idle_tasks") - def test_childless_body_omits_caveat_card_meta_unchanged(self, coord_setup): + def test_childless_body_omits_children_facts_card_meta_unchanged(self, coord_setup): """The measured harm, removed: a coordinator with no children is no longer told to go and check on children it does not have. @@ -1418,28 +1423,38 @@ class TestTasksBodyChildrenCaveat: mgr, storage, ws = coord_setup _type, text, meta = self._fire(mgr, storage, ws) - assert NUDGE_IDLE_TASKS_CHILDREN_CAVEAT not in text - assert "may still be running" not in text - # The rest of the body is untouched — this removes a sentence, - # not the opener it rides or the branches after it. + assert "Child " not in text + assert "child" not in text + # The rest of the body is untouched — this omits the fact lines, + # not the counts line they ride under or the branches after it. assert text.startswith("You still have 1 open task: 1 in_progress, 0 pending.") assert "needs_user" in text assert meta == {"counts": {"open": 1, "in_progress": 1, "pending": 0}} def test_idle_child_keeps_the_caveat(self, coord_setup): - """The stranded-children case: the load-bearing detector for - a probe wired to ``_ACTIVE_CHILD_STATES`` instead of - ``_LIVE_CHILD_STATES``, which no gate-matrix or metadata test - can see (they all seed running children or none). Its siblings - are ``test_every_live_state_keeps_the_caveat[idle]`` and the + """The stranded-children case, and the C6b protection: the + stopped-child fact line must state the stop and the immediate + wait — the retired hedge's "may have finished" disjunct, now + attached to the one child it is true of, with the id the model + needs to check. It asserts NOTHING about results ("may hold + uncollected results" was cut as a fabrication — no read ever + observed results existing); the immediate wait is the whole + protection, because checking is cheap and finds whatever is + there. Load-bearing detector for a probe wired to + ``_ACTIVE_CHILD_STATES`` instead of ``_LIVE_CHILD_STATES``, + which no gate-matrix or metadata test can see (they all seed + running children or none). Its siblings are + ``test_every_live_state_renders_its_fact_line[idle]`` and the eval-parity guard's ``idle_child`` scenario; this one carries - the reason. + the reason. (The name predates the fact lines — "the caveat" + here IS the stopped-child line — and stays so the C6b pointer + in the fixture notes keeps resolving.) An idle child is not actionable — the liveness path will not nudge about it, which is why no co-delivered entry appears here — but it exists, and it may hold results nobody has collected. - Conditioning on the ACTIVE set would strip the hedge from the - one state whose protective disjunct is live. + Conditioning on the ACTIVE set would drop the line from the one + state whose protection is live. """ mgr, storage, ws = coord_setup # ``_add_active_child`` seeds ANY state despite its name. @@ -1447,36 +1462,53 @@ class TestTasksBodyChildrenCaveat: _type, text, _meta = self._fire(mgr, storage, ws) - assert NUDGE_IDLE_TASKS_CHILDREN_CAVEAT in text + assert ( + "Child child-a has stopped — wait_for_workstream returns immediately for it." + ) in text + # The observed state renders as itself — never the hedge, never + # the other state's line. + assert "Child child-a is still running" not in text + assert "may still be running" not in text # The liveness path stays out of it: an idle child is not - # actionable, so this really is the advice nudge alone hedging + # actionable, so this really is the advice nudge alone speaking # about a child nobody was woken for. assert [t for t, _ in ws.session._nudge_queue.pending("any")] == ["idle_tasks"] @pytest.mark.parametrize("state", sorted(_LIVE_CHILD_STATES)) - def test_every_live_state_keeps_the_caveat(self, coord_setup, state): + def test_every_live_state_renders_its_fact_line(self, coord_setup, state): """The membership is the enum, not a list someone maintains: a state added to ``WorkstreamState`` joins the live set with no - edit, and this parametrization grows with it.""" + edit, and this parametrization grows with it. Each state gets + the line that is TRUE of it: wait-terminal states (idle/error) + the stopped line with its immediate-wait point, everything else + the still-running line.""" mgr, storage, ws = coord_setup _add_active_child(storage, ws_id="child-a", state=state) _type, text, _meta = self._fire(mgr, storage, ws) - assert NUDGE_IDLE_TASKS_CHILDREN_CAVEAT in text + if state in NUDGE_CHILD_STOPPED_STATES: + assert ( + "Child child-a has stopped — wait_for_workstream returns immediately for it." + in text + ) + else: + assert "Child child-a is still running; check before redoing anything it owns." in text @pytest.mark.parametrize("state", ["closed", "deleted"]) def test_terminal_child_rows_read_as_childless(self, coord_setup, state): """``closed`` and ``deleted`` are strings the close and reap paths write, and they are NOT ``WorkstreamState`` members — a - coordinator whose every child row is terminal has no children to - hedge about, however many rows storage still holds.""" + coordinator whose every child row is terminal has no children + for the body to speak about, however many rows storage still + holds.""" mgr, storage, ws = coord_setup _add_active_child(storage, ws_id="child-a", state=state) _type, text, _meta = self._fire(mgr, storage, ws) - assert NUDGE_IDLE_TASKS_CHILDREN_CAVEAT not in text + assert "Child " not in text + assert "child" not in text def test_the_advice_fire_is_identical_across_every_answer_the_read_returns(self): """The ruling, restated to the width it actually holds: what the @@ -1773,6 +1805,17 @@ class TestExecutableCalls: assert "child_ws_id='child-a'" in text assert "child_ws_id='child-b'" not in text assert NUDGE_IDLE_TASKS_CHILD_SLOT not in text + # ...and each child gets its OWN observed fact line, in read + # order — the running one the redo protection, the idle one the + # stop and the immediate wait — so the mixed state renders as + # two facts, never one hedge covering both. + lines = text.split(chr(10)) + assert lines[1] == ( + "Child child-a is still running; check before redoing anything it owns." + ) + assert lines[2] == ( + "Child child-b has stopped — wait_for_workstream returns immediately for it." + ) def test_the_blocked_branch_is_a_call_not_prose(self, coord_setup): """MUTATION CONTROL: reverting the wait to prose fails here. @@ -1830,9 +1873,8 @@ class TestExecutableCalls: text = self._body(mgr, storage, ws) - assert NUDGE_IDLE_TASKS_CHILDREN_CAVEAT not in text assert NUDGE_IDLE_TASKS_CHILD_SLOT not in text - for fragment in ("child", "wait_for_workstream", "list_workstreams"): + for fragment in ("child", "Child", "wait_for_workstream", "list_workstreams"): assert fragment not in text, fragment # Two calls, not three — and the ones that remain are populated. assert self._task_id_args(text) == ["tsk_a"] * 2 @@ -1997,7 +2039,7 @@ class TestIndeterminateChildrenRead: pending = ws.session._nudge_queue.pending("any") assert [t for t, _ in pending] == ["idle_tasks"] - assert NUDGE_IDLE_TASKS_CHILDREN_CAVEAT not in pending[0][1] + assert "Child " not in pending[0][1] def test_liveness_does_not_fire_when_children_read_fails(self, coord_setup): mgr, storage, ws = coord_setup @@ -3068,7 +3110,7 @@ class TestEvalParity: ``_open_task_ids``) and calls the real formatter — the eval carries no copy of any comprehension. Sharing the derivation removes one drift class; this guard covers the one that remains: the OBSERVER's - plumbing (envelope read → counts + ids → ``child_ws_ids`` argument → + plumbing (envelope read → counts + ids → ``children`` argument → formatter → card meta) can still diverge from the eval's re-derivation over the same envelope, and every sweep would then score a body that is not the one shipping, with the merge decision @@ -3079,13 +3121,13 @@ class TestEvalParity: re-derivations agreeing proves nothing about what production emits. Run on TWO coordinator states, because the body is not one string: - the caveat sentence AND the two child slots are conditioned on which - child rows exist, so "the eval renders what production renders" is - two claims, one per cell class, and a guard that only ever saw the - childless state would go green against an eval wired to the wrong - branch on every cell that has children. + the per-child fact lines AND the two child slots are conditioned on + which child rows exist, so "the eval renders what production + renders" is two claims, one per cell class, and a guard that only + ever saw the childless state would go green against an eval wired + to the wrong branch on every cell that has children. - ``expect_child_ws_ids`` is declared per scenario rather than derived, + ``expect_children`` is declared per scenario rather than derived, so each branch is a written-down claim about what production emits in that state rather than the same derivation run twice. It is what makes a PARTIAL update fail: while the observer passed a literal @@ -3093,29 +3135,30 @@ class TestEvalParity: flipped in the same commit that replaced that literal with a real read. Either half of a pair like that landing alone trips here — an observer re-wired to a literal fails ``no_children``, and an eval - that stopped deriving the value fails it too. Declaring the ID - rather than a bool is what extends that property to the populated - slots: an observer that read the right ROWS but projected the wrong - field would still have satisfied a bool. + that stopped deriving the value fails it too. Declaring the + ``(ws_id, state)`` PAIR rather than a bool is what extends that + property to the fact lines and the populated slots: an observer that + read the right ROWS but projected the wrong field — or the id + without the state — would still have satisfied a bool. """ @pytest.mark.parametrize( - ("children", "expect_child_ws_ids"), + ("children", "expect_children"), [ pytest.param([], [], id="no_children"), - # An IDLE child: a row that exists (so the caveat speaks + # An IDLE child: a row that exists (so the fact lines speak # about something real) but that the liveness path will not # nudge about, so this scenario stays a single-entry # comparison rather than turning into a co-delivery test. # It is also the second detector for a read miswired to the # ACTIVE set — that bug renders the childless body here, - # against an eval that (correctly) derives the id. + # against an eval that (correctly) derives the pair. # ``child-1`` is ``_add_active_child``'s default ws_id. - pytest.param([{"state": "idle"}], ["child-1"], id="idle_child"), + pytest.param([{"state": "idle"}], [("child-1", "idle")], id="idle_child"), ], ) def test_eval_stimulus_matches_production_formatter( - self, coord_setup, children, expect_child_ws_ids + self, coord_setup, children, expect_children ): mgr, storage, ws = coord_setup # ``_add_active_child`` seeds ANY state despite its name — it @@ -3186,7 +3229,7 @@ class TestEvalParity: # 1. The model-facing body is byte-identical to the eval's, on # the branch this coordinator state selects. - assert text == render_tasks_body(envelope, child_ws_ids=expect_child_ws_ids) + assert text == render_tasks_body(envelope, children=expect_children) assert text.startswith("You still have 6 open tasks: 2 in_progress, 4 pending.") # 2. The operator card records the counts the body told the diff --git a/tests/test_eval_nudges.py b/tests/test_eval_nudges.py index 1e1a0cd7..f6f4b11d 100644 --- a/tests/test_eval_nudges.py +++ b/tests/test_eval_nudges.py @@ -21,8 +21,9 @@ import pytest from turnstone.console.coordinator_idle_observer import _ACTIVE_CHILD_STATES from turnstone.core.metacognition import ( + NUDGE_IDLE_TASKS_CHILD_DOOR, NUDGE_IDLE_TASKS_CHILD_SLOT, - NUDGE_IDLE_TASKS_CHILDREN_CAVEAT, + NUDGE_IDLE_TASKS_ID_SLOT, ) from turnstone.core.session import COORDINATOR_TOOLS from turnstone.core.storage._registry import ( @@ -644,7 +645,7 @@ class TestSweepValidation: def test_no_caveat_arm_with_only_terminal_children_is_refused(self): """``closed`` / ``deleted`` are the strings the close and reap paths write, and they are NOT ``WorkstreamState`` members — a row - in one of them is gone, so the cell is childless for the caveat's + in one of them is gone, so the cell is childless for the body's purposes however many rows it declares.""" for state in ("closed", "deleted"): cell = { @@ -669,8 +670,8 @@ class TestSweepValidation: def test_no_caveat_arm_passes_on_any_live_child_including_idle(self): """EXISTENCE-in-a-live-state, deliberately broader than the pair arms' ACTIVE predicate: C6b's idle child — a child that finished - with results nobody collected — is precisely the state the - caveat's second disjunct covers, and it fails the pair-arm check + with results nobody collected — is precisely the row the + stopped-child fact line protects, and it fails the pair-arm check (``test_pair_arm_with_only_inactive_children_is_refused``) while passing this one. Both are correct.""" for state in sorted(_LIVE_CHILD_STATES): @@ -696,6 +697,89 @@ class TestSweepValidation: } _validate_cells([cell]) + def test_a_childless_cell_is_refused_under_an_override(self): + """Item: an override sweep can no longer force children content + into a childless world, STRUCTURALLY. A childless cell renders + the formatter's childless branch, whose literal door cut would + silently strip a candidate that quotes the shipped + blocked-on-a-child branch — so the sweep refuses the cell at + config validation, naming the cell and the reason, before any + model round-trip. Without an override the same cell passes: + the shipped tail is exactly what the cut is defined against. + """ + cell = { + "id": "X_override_childless", + "arms": [ARM_NUDGE], + "children": [], + "tasks": [_OPEN_TASK], + } + _validate_cells([cell]) # fine against the shipped body + with pytest.raises(SystemExit) as excinfo: + _validate_cells([cell], override_active=True) + message = str(excinfo.value) + assert "X_override_childless" in message, message + assert "body-override" in message, message + assert "live state" in message, message + + def test_a_terminal_only_children_cell_is_refused_under_an_override(self): + """The refusal keys on the LIVE derivation, not the raw list: a + cell whose every child row is terminal is a childless world to + the formatter, so a raw-list predicate would wave it through and + the door cut would maul the candidate anyway.""" + cell = { + "id": "X_override_terminal", + "arms": [ARM_NUDGE], + "children": [ + { + "ws_id": "ws-c1", + "name": "auditor", + "state": "closed", + "transcript": [_ASSIGNMENT_ROW, _FINDINGS_ROW], + } + ], + "tasks": [_OPEN_TASK], + } + _validate_cells([cell]) + with pytest.raises(SystemExit, match="X_override_terminal"): + _validate_cells([cell], override_active=True) + + def test_a_live_child_cell_passes_under_an_override(self): + """The refusal is exactly as wide as the maul: any live-state + child row keeps the door in play, so the cell measures the + candidate as authored (idle included — the C6b class is a legal + override cell).""" + for state in ("running", "idle"): + cell = { + "id": f"X_override_{state}", + "arms": [ARM_NUDGE], + "children": [ + { + "ws_id": "ws-c1", + "name": "auditor", + "state": state, + "transcript": [_ASSIGNMENT_ROW] + + ([_FINDINGS_ROW] if state == "idle" else []), + } + ], + "tasks": [_OPEN_TASK], + } + _validate_cells([cell], override_active=True) + + def test_the_shipped_cells_that_survive_an_override_are_the_children_ones(self): + """The shipped grid under ``--body-override``: exactly the two + children-bearing cells validate; every childless cell is refused + by name. (An override sweep therefore runs with ``--cells + C6_co_delivery,C6b_stranded_children`` or a fixture edit — never + with a silently mauled childless body.)""" + with_children = [c for c in NUDGE_CELLS if _live_children(c.get("children") or [])] + assert {c["id"] for c in with_children} == {"C6_co_delivery", "C6b_stranded_children"} + _validate_cells(with_children, override_active=True) + for cell in NUDGE_CELLS: + if cell in with_children: + continue + with pytest.raises(SystemExit, match=cell["id"]): + _validate_cells([cell], override_active=True) + def test_the_cells_that_declare_no_caveat_are_the_ones_with_children(self): """The shipped grid, named rather than assumed. @@ -951,7 +1035,9 @@ class TestRefusalsPrecedeTheCanary: _SENTINEL = "canary-probe-entered" @classmethod - def _sweep(cls, monkeypatch, cells: list[dict[str, Any]]) -> None: + def _sweep( + cls, monkeypatch, cells: list[dict[str, Any]], *, override: str | None = None + ) -> None: from turnstone.eval import nudges as nudges_module def _probe_sentinel(*a: Any, **k: Any) -> bool: @@ -968,6 +1054,7 @@ class TestRefusalsPrecedeTheCanary: model="eval-model", cells=cells, n_runs=1, + body_override_text=override, ) def test_a_good_cell_reaches_the_probe(self, monkeypatch): @@ -1001,6 +1088,20 @@ class TestRefusalsPrecedeTheCanary: self._sweep(monkeypatch, [cell]) assert self._SENTINEL not in str(excinfo.value), cell + def test_the_override_refusal_fires_before_the_probe(self, monkeypatch): + """The override-only class binds at the same point as every + other refusal: config time, zero model round-trips. Both + directions, like the class docstring demands — the childless + cell refuses WITHOUT the sentinel, and the same cell without an + override reaches the probe (so the refusal really is + override-conditional).""" + cell = {"id": "X_t", "arms": [ARM_NUDGE], "children": [], "tasks": [_OPEN_TASK]} + with pytest.raises(SystemExit) as excinfo: + self._sweep(monkeypatch, [cell], override="Candidate wording.") + assert self._SENTINEL not in str(excinfo.value) + with pytest.raises(RuntimeError, match=self._SENTINEL): + self._sweep(monkeypatch, [cell]) + def test_a_duplicate_cell_id_fires_before_the_probe(self, monkeypatch): """The one refusal that reads the whole list rather than a single cell, so it needs its own two-cell fixture.""" @@ -1067,13 +1168,14 @@ class TestStimulus: def test_bodies_are_production_rendered(self): """The eval carries no copy of the body: the real formatter over - the really-seeded envelope — the counts opener, the id block, the - co-delivery honesty line and the escape branch, populated with - the seeded id and the seeded child, and NO task text.""" + the really-seeded envelope — the counts opener, the per-child + observed fact line, the id block and the escape branch, + populated with the seeded id and the seeded child, and NO task + text.""" env = _envelope({"id": "tsk_1", "title": "audit auth.py", "status": "pending"}) - body = render_tasks_body(env, child_ws_ids=["ws-c1"]) + body = render_tasks_body(env, children=[("ws-c1", "running")]) assert body.startswith("You still have 1 open task: 0 in_progress, 1 pending.") - assert "may still be running" in body + assert "Child ws-c1 is still running; check before redoing anything it owns." in body assert "needs_user" in body # The seeded id reaches the block AND the branch calls; the # seeded title reaches neither. @@ -1088,32 +1190,32 @@ class TestStimulus: ablation cannot drift from the body that ships. THE ARM ABLATES THE BODY'S WHOLE CHILDREN AWARENESS, by design. - ``child_ws_ids`` governs the caveat sentence, the + The ``children`` pairs govern the per-child fact lines, the blocked-on-a-child branch and that branch's slots, because in - production all of them answer ONE storage read and a bool beside - a list is two derivations of one question. So the arm asks a - single clean question — does this body need to mention children - at all? — rather than the narrower "what does the sentence buy?" - it was named for. That is a better question, which is why the - arm is worth keeping; the name stays because archived sweeps - report under it. + production all of them answer ONE storage read and a second + derivation of it is a state where two answers can disagree. So + the arm asks a single clean question — does this body need to + mention children at all? — rather than the narrower "what does + the sentence buy?" it was named for. That is a better question, + which is why the arm is worth keeping; the name stays because + archived sweeps report under it. This scope is not drift. It is what "the formatter's other - branch" MEANS now that one condition governs every - children-bearing element, and the alternative — a knob that - stripped the sentence while keeping the branch — would render a - body no coordinator receives, which is the one thing an eval arm - may never do. + branch" MEANS now that one read governs every children-bearing + element, and the alternative — a knob that stripped the fact + lines while keeping the branch — would render a body no + coordinator receives, which is the one thing an eval arm may + never do. """ env = _envelope({"id": "tsk_1", "title": "audit auth.py", "status": "pending"}) - kept = render_tasks_body(env, child_ws_ids=["ws-c1"]) - ablated = render_tasks_body(env, child_ws_ids=[]) + kept = render_tasks_body(env, children=[("ws-c1", "running")]) + ablated = render_tasks_body(env, children=[]) - assert NUDGE_IDLE_TASKS_CHILDREN_CAVEAT in kept + assert "Child ws-c1 is still running" in kept assert "child_ws_id='ws-c1'" in kept # NOTHING about children survives the ablation — asserted as # absence of the topic, so a reworded branch cannot pass. - for absent in ("child", "wait_for_workstream", "list_workstreams", "may still be running"): + for absent in ("child", "Child", "wait_for_workstream", "list_workstreams"): assert absent not in ablated, absent # ...and nothing else moves. The counts opener, the id block and # the remaining branches survive: this arm ablates an @@ -1126,16 +1228,20 @@ class TestStimulus: turns = build_stimulus(ARM_NO_CAVEAT, envelope=env, children=[_LIVE_CHILD]) assert turns[1]["content"] == ablated - def test_the_nudge_arm_derives_the_caveat_from_the_cells_children(self): + def test_the_nudge_arm_derives_the_children_facts_from_the_cells_children(self): """The ``nudge`` arm renders what a coordinator in that cell's state would really receive, which since the observer's probe - landed is two different bodies by cell class. + landed is two different bodies by cell class — and for the + children-bearing class, the OBSERVED per-state fact lines. The predicate is EXISTENCE in a live state, the observer's own: - an idle child keeps the caveat (it may hold results nobody - collected) while a terminal row does not count as a child at - all. Deriving through ``_live_children`` rather than the pair - arms' active filter is what those two rows detect. + an idle child keeps its stopped-with-immediate-wait line (it + may hold results nobody collected) while a terminal row does not + count as a child at all. Deriving through ``_live_children`` + rather than the pair arms' active filter is what those two rows + detect — and the STATE riding beside the id is what makes the + idle row render as the fact it is instead of a running claim or + a hedge. """ env = _envelope({"id": "tsk_1", "title": "audit auth.py", "status": "pending"}) @@ -1143,50 +1249,28 @@ class TestStimulus: turns = build_stimulus(ARM_NUDGE, envelope=env, children=children) return str(turns[1]["content"]) - assert NUDGE_IDLE_TASKS_CHILDREN_CAVEAT not in _body([]) - assert NUDGE_IDLE_TASKS_CHILDREN_CAVEAT in _body([_LIVE_CHILD]) - assert NUDGE_IDLE_TASKS_CHILDREN_CAVEAT in _body( - [{"ws_id": "ws-c1", "name": "auditor", "state": "idle"}] + assert "Child " not in _body([]) + assert "Child ws-c1 is still running; check before redoing anything it owns." in _body( + [_LIVE_CHILD] ) - assert NUDGE_IDLE_TASKS_CHILDREN_CAVEAT not in _body( - [{"ws_id": "ws-c1", "name": "auditor", "state": "closed"}] - ) - - def test_an_indeterminate_read_renders_the_children_aware_body(self): - """``None`` reaches the formatter unchanged, and the formatter - hedges — the eval must not launder a failed read into a - measurement of the childless body. - - It is the case a naive "drop when empty" gets wrong, and the one - that keeps the blocked-on-a-child branch alive on a failed read. - The branch is kept and its slots stay placeholders, which is the - honest state: the read that failed is the HARNESS's, so the - model's own ``list_workstreams()`` may well answer. A formatter - that instead invented a ws_id from an unknown read would put a - call the model cannot run in front of it, which is the exact - failure the invented ``a1b2c3d4`` constant used to cause. - """ - env = _envelope({"id": "tsk_1", "title": "audit auth.py", "status": "pending"}) - hedged = render_tasks_body(env, child_ws_ids=None) - childless = render_tasks_body(env, child_ws_ids=[]) - - assert NUDGE_IDLE_TASKS_CHILDREN_CAVEAT in hedged - assert "If an item is waiting on a child workstream" in hedged - # No fabricated child anywhere: an unknown read leaves both child - # slots as their honest placeholders. - assert "ws-c1" not in hedged - assert hedged.count(NUDGE_IDLE_TASKS_CHILD_SLOT) == 2 - # The childless body is the same one minus every children-bearing - # element, so the hedged/childless pair is the whole conditional. - for absent in ("child", "wait_for_workstream", "list_workstreams"): - assert absent not in childless, absent + idle_body = _body([{"ws_id": "ws-c1", "name": "auditor", "state": "idle"}]) + assert ( + "Child ws-c1 has stopped — " + "wait_for_workstream returns immediately for it." + ) in idle_body + assert "Child " not in _body([{"ws_id": "ws-c1", "name": "auditor", "state": "closed"}]) + # No hedge about an observed state, in any cell class. + for children in ([], [_LIVE_CHILD], [{"ws_id": "ws-c1", "state": "idle"}]): + body = _body(children) + assert "may still be running" not in body, children + assert "while you worked" not in body, children def test_ragged_envelope_rows_do_not_raise(self): env = _envelope( {"id": "tsk_1", "title": None, "status": "pending", "note": 42}, "not-a-dict-row", ) - body = render_tasks_body(env, child_ws_ids=["ws-c1"]) + body = render_tasks_body(env, children=[("ws-c1", "running")]) assert body.startswith("You still have 1 open task") def test_seed_transcript_pairs_calls_with_results(self): @@ -1301,78 +1385,53 @@ class TestBodyOverrideSkip: assert "skipped" not in cell[arm] assert cell[arm]["n"] == 2 - def test_override_never_applies_the_caveat_conditional(self): - """A candidate that keeps the caveat sentence verbatim while - rewording another paragraph is the LIKELIEST tuning shape there - is, and it is the one the literal-anchored cut would silently - maul: the sentence is present, so the removal lands, and a - childless cell would file a caveat-stripped candidate under the - heading of the wording the operator actually wrote. + def test_a_children_cell_measures_a_door_quoting_candidate_as_authored(self): + """The LIKELIEST candidate shape there is: one that keeps the + shipped blocked-on-a-child branch verbatim while rewording + another paragraph. On a children cell — the only cell class an + override sweep still admits — the formatter's door cut never + runs (children are present), so the candidate's quoted branch + survives byte-exact and its slots are populated exactly as + production would populate the shipped text. The childless cell + that would have mauled this candidate is refused at config time + (``test_a_childless_cell_is_refused_under_an_override``), which + is the structural half of the same protection. - So the conditional is off whenever an override is installed — - every arm, every cell, including the arm whose whole definition - is the other branch. Asserted through the REAL override context - manager, because the mechanism under test is the one that swaps - the module constant. + Asserted through the REAL override context manager, because the + mechanism under test is the one that swaps the module constant. """ from turnstone.eval import nudges as nudges_module env = _envelope({"id": "tsk_1", "title": "audit auth.py", "status": "pending"}) - candidate = ( - NUDGE_IDLE_TASKS_CHILDREN_CAVEAT - + chr(10) * 2 - + "Reworded branch paragraph: use tasks(action='update', " - + "task_id='tsk_...', status='needs_user')." - ) + candidate = "Reworded opening paragraph: reconcile your list." + NUDGE_IDLE_TASKS_CHILD_DOOR with nudges_module._body_override(candidate): - forced = render_tasks_body(env, child_ws_ids=[], override_active=True) - through_the_arm = build_stimulus( - ARM_NO_CAVEAT, envelope=env, children=[_LIVE_CHILD], override_active=True - ) - # The control: without the override rule this is what the - # candidate would have been reported as. - unprotected = render_tasks_body(env, child_ws_ids=[]) + rendered = render_tasks_body(env, children=[("ws-c1", "running")]) + through_the_arm = build_stimulus(ARM_NUDGE, envelope=env, children=[_LIVE_CHILD]) + # The control: the childless branch really would cut the + # quoted door out of this candidate — the maul the refusal + # and the skip exist to make unreachable. + mauled = render_tasks_body(env, children=[]) - assert NUDGE_IDLE_TASKS_CHILDREN_CAVEAT in forced - # The candidate tail rides byte-exact after the formatter-built - # counts opener — the opener is seeded state, not tuned text, so - # an override never touches it. - assert forced == "You still have 1 open task: 0 in_progress, 1 pending." + candidate - assert through_the_arm[1]["content"] == forced - assert NUDGE_IDLE_TASKS_CHILDREN_CAVEAT not in unprotected, ( + # Formatter-built facts lead; the candidate rides after them + # with its quoted door substituted, never stripped. + assert rendered.startswith( + "You still have 1 open task: 0 in_progress, 1 pending." + + chr(10) + + "Child ws-c1 is still running; check before redoing anything it owns." + ) + assert "record the link and wait instead of redoing its work" in rendered + assert "child_ws_id='ws-c1'" in rendered + assert NUDGE_IDLE_TASKS_CHILD_SLOT not in rendered + # The candidate's own task-id slot has no open-list machinery in + # this candidate, so it substitutes from the seeded set like the + # shipped tail's would. + assert "task_id='tsk_1'" in rendered + assert NUDGE_IDLE_TASKS_ID_SLOT not in rendered + assert through_the_arm[1]["content"] == rendered + assert "record the link and wait instead of redoing its work" not in mauled, ( "the control must show the cut really lands on candidate text" ) - def test_the_runner_threads_the_override_flag_into_every_run(self, monkeypatch): - """The skip covers the two ablation arms; ``override_active`` is - what protects the REST of the grid, whose bodies are rendered - from the candidate text. Pinned on the argument the runs receive - — a runner that computed the flag and dropped it would leave the - conditional live against unknown text, which is the whole class - the skip exists to close.""" - from turnstone.eval import nudges as nudges_module - - seen: list[bool] = [] - - def _fake_run(**kw: Any) -> dict[str, Any]: - seen.append(kw["override_active"]) - return {"pass": True, "failures": [], "forbidden": [], "actions": ["tasks"]} - - monkeypatch.setattr(nudges_module, "tool_call_canary", lambda *a, **k: True) - monkeypatch.setattr(nudges_module, "_run_single_nudge", _fake_run) - cells = [{"id": "X_tuning", "arms": [ARM_NUDGE], "tasks": [_OPEN_TASK]}] - for override, expected in (("Candidate wording.", True), (None, False)): - seen.clear() - run_nudge_response( - base_url="http://eval.invalid/v1", - api_key="x", - model="eval-model", - cells=cells, - n_runs=2, - body_override_text=override, - ) - assert seen == [expected, expected], override - class TestBodyFingerprint: """A result file must say WHICH body produced its numbers. @@ -1385,7 +1444,9 @@ class TestBodyFingerprint: """ @staticmethod - def _sweep(monkeypatch, *, override: str | None) -> dict[str, Any]: + def _sweep( + monkeypatch, *, override: str | None, cells: list[dict[str, Any]] | None = None + ) -> dict[str, Any]: from turnstone.eval import nudges as nudges_module monkeypatch.setattr(nudges_module, "tool_call_canary", lambda *a, **k: True) @@ -1398,7 +1459,9 @@ class TestBodyFingerprint: base_url="http://eval.invalid/v1", api_key="x", model="eval-model", - cells=[ + cells=cells + if cells is not None + else [ {"id": "X_a", "arms": [ARM_NUDGE], "tasks": [_OPEN_TASK]}, { "id": "X_b", @@ -1436,21 +1499,52 @@ class TestBodyFingerprint: def test_the_fingerprint_is_of_the_effective_tail_under_an_override(self, monkeypatch): """Taken INSIDE the override context, so the hash is of the text the runs really saw — a fingerprint of the shipped tail under a - tuning sweep would be a false provenance stamp, which is worse - than none.""" + candidate sweep would be a false provenance stamp, which is + worse than none. + + An override sweep admits only children-bearing cells (the + childless class is refused at config time), so the default + two-cell fixture cannot serve here — both cells carry a live + child, and both stamp the ``children_present`` fact their runs + really rendered, DERIVED rather than forced by a flag. + """ from turnstone.core import metacognition as metacog candidate = "Candidate wording under test." - out = self._sweep(monkeypatch, override=candidate) + out = self._sweep( + monkeypatch, + override=candidate, + cells=[ + { + "id": "X_b", + "arms": [ARM_NUDGE], + "children": [_LIVE_CHILD], + "tasks": [_OPEN_TASK], + }, + { + "id": "X_c", + "arms": [ARM_NUDGE], + "children": [ + { + "ws_id": "ws-c9", + "name": "researcher", + "state": "idle", + "transcript": [ + _ASSIGNMENT_ROW, + _FINDINGS_ROW, + ], + } + ], + "tasks": [_OPEN_TASK], + }, + ], + ) assert out["body"]["override"] is True assert out["body"]["tail_sha256"] == hashlib.sha256(candidate.encode()).hexdigest() - # The conditional is off under an override, on EVERY cell — so - # the childless cell stamps ``True`` here, matching the body its - # runs really received rather than the fact its fixture holds. assert out["body"]["cells"] == { - "X_a": {"children_present": True}, "X_b": {"children_present": True}, + "X_c": {"children_present": True}, } # ...and the constant is restored afterwards, so the stamp is # not evidence of a leaked override. diff --git a/tests/test_idle_nudge_wake_integration.py b/tests/test_idle_nudge_wake_integration.py index 440cf43e..5db6ecf9 100644 --- a/tests/test_idle_nudge_wake_integration.py +++ b/tests/test_idle_nudge_wake_integration.py @@ -32,7 +32,7 @@ from tests._helpers import wait_until as _wait_until from tests.test_session_manager import FakeStorage from turnstone.core import session_worker from turnstone.core.idle_nudge_watcher import IdleNudgeWatcher, wake_workstream_if_pending -from turnstone.core.metacognition import NUDGE_IDLE_TASKS_CHILDREN_CAVEAT +from turnstone.core.metacognition import NUDGE_IDLE_TASKS_CHILD_DOOR from turnstone.core.session import ChatSession from turnstone.core.session_manager import SessionManager from turnstone.core.trajectory import dicts_from_turns, turn_from_dict @@ -563,10 +563,15 @@ def test_coord_idle_with_children_and_open_tasks_delivers_both(coord_mgr, tmp_db assert chr(10) + " - tsk_a (in_progress)" in tasks_text assert "task_id='tsk_a'" in tasks_text assert "audit auth.py" not in tasks_text - # The caveat branch the read selects when a live child row is - # really in storage — the populated half of the pair whose empty - # half is the test below. - assert "may still be running" in tasks_text + # The children-aware branch the read selects when a live child + # row is really in storage — the populated half of the pair + # whose empty half is the test below. The fact line renders the + # OBSERVED state (registered running) with the full id, end to + # end through the real storage round-trip. + assert ( + "Child child-a is still running; check before redoing anything it owns." in tasks_text + ) + assert "may still be running" not in tasks_text # CO-DELIVERY COHERENCE, end to end: both bodies in one drain now # name the same child, from two independent storage reads. The # tasks body populates its blocked-on-a-child branch with the @@ -591,15 +596,16 @@ def test_coord_idle_with_children_and_open_tasks_delivers_both(coord_mgr, tmp_db observer.shutdown() -def test_coord_idle_with_open_tasks_and_no_children_omits_the_caveat(coord_mgr, tmp_db): +def test_coord_idle_with_open_tasks_and_no_children_omits_children_content(coord_mgr, tmp_db): """The childless sibling of the test above, over the same chain: - no child rows registered, so the enqueue-time existence aggregate - answers "none" and the DELIVERED body says nothing about children. + no child rows registered, so the enqueue-time live-children read + answers "none" and the DELIVERED body says nothing about children — + no fact lines, no blocked-on-a-child branch. Asserted on the transcript rather than on the formatter's return, because the claim is about what the coordinator is actually told: - the probe queries the storage backend through the real - ``count_workstreams_by_state``, and every boundary between that read + the body's children read queries the storage backend through the + real ``list_workstreams``, and every boundary between that read and the system turn — observer, queue, watcher, wake worker, ``deliver_wake_nudge_from_queue`` — is production code. """ @@ -659,10 +665,12 @@ def test_coord_idle_with_open_tasks_and_no_children_omits_the_caveat(coord_mgr, m["content"] for m in msgs if m.get("role") == "system" and m["_source"] == "idle_tasks" ) assert tasks_text.startswith("You still have 1 open task: 1 in_progress, 0 pending.") - assert "may still be running" not in tasks_text - assert NUDGE_IDLE_TASKS_CHILDREN_CAVEAT not in tasks_text + assert "Child " not in tasks_text + assert "child" not in tasks_text + assert NUDGE_IDLE_TASKS_CHILD_DOOR not in tasks_text # The nudge is otherwise the shipped one: the conditional drops - # a sentence, not the opener, the id block or the instructions. + # the fact lines and the blocked-on-a-child branch, not the + # opener, the id block or the other instructions. assert "needs_user" in tasks_text # End to end, through the REAL storage round-trip: the id that # went into ``workstream_config`` comes back out populated into diff --git a/tests/test_metacognition.py b/tests/test_metacognition.py index 9d30224f..e3c33eb4 100644 --- a/tests/test_metacognition.py +++ b/tests/test_metacognition.py @@ -1,15 +1,18 @@ """Tests for turnstone.core.metacognition — detection, nudging, formatting.""" +import pytest + from turnstone.core.metacognition import ( MEMORY_NUDGE_TYPES, + NUDGE_CHILD_STOPPED_STATES, NUDGE_COMPLETION, NUDGE_CORRECTION, NUDGE_DENIAL, NUDGE_IDLE_CHILDREN_DISPLAY_CAP, + NUDGE_IDLE_CHILDREN_HEADER, NUDGE_IDLE_CHILDREN_WAIT_CAP, NUDGE_IDLE_TASKS_CHILD_DOOR, NUDGE_IDLE_TASKS_CHILD_SLOT, - NUDGE_IDLE_TASKS_CHILDREN_CAVEAT, NUDGE_IDLE_TASKS_ID_SLOT, NUDGE_IDLE_TASKS_OPEN_LIST_SLOT, NUDGE_IDLE_TASKS_TAIL, @@ -412,9 +415,42 @@ class TestFormatIdleChildrenNudge: def test_single_child_renders_id_and_state(self): children = [{"ws_id": "ws-abc12345", "state": "running"}] text = format_idle_children_nudge(children) - assert "ws-abc12 (running)" in text # short-id form (8 chars) + state + assert " - ws-abc12345 (running)" in text # FULL id + state assert "wait_for_workstream" in text - assert "ws-abc12345" in text # full id appears in the suggestion's ws_ids list + assert wait_call(["ws-abc12345"]) in text + + def test_header_opens_with_the_drain_verified_fact_only(self): + """The body's first sentence is the one claim the drain predicate + re-verifies — children are still active. "You are idle." led it + until 2026-07-29 and was dropped: a queued entry delivers at + whichever seam arrives next, and the predicate re-checks the + CHILDREN, never the coordinator's idleness, so that sentence + could be false at delivery. The harness must not render a state + it does not hold. + """ + text = format_idle_children_nudge([{"ws_id": "ws-abc12345", "state": "running"}]) + assert text.startswith("These child workstreams are still active:") + assert text.startswith(NUDGE_IDLE_CHILDREN_HEADER) + assert "You are idle" not in text + assert "idle" not in NUDGE_IDLE_CHILDREN_HEADER + + def test_bullets_carry_the_full_ws_id_as_a_handle(self): + """The roster exists to hand the model HANDLES for inspect / + message / wait, and ``_resolve_ws_ref`` refuses truncated ids by + design (near-miss ids are NEVER auto-resolved) — so an 8-char + prefix bullet was not a handle, it was a value the resolver + rejects sitting one line above the full id that works. The + bullet and the wait line carry the SAME full id; prefixing for + readability is the FE's display concern, not this body's. + """ + full = "a0b1c2d3e4f5061728394a5b6c7d8e9f" + text = format_idle_children_nudge([{"ws_id": full, "state": "running"}]) + bullets = [ln for ln in text.splitlines() if ln.startswith(" - ")] + assert bullets == [f" - {full} (running)"] + assert f"To block on them: {wait_call([full])}." in text + # MUTATION CONTROL for the truncation returning: no bullet + # carries the 8-char prefix form. + assert f" - {full[:8]} (" not in text def test_model_authored_names_never_reach_the_body(self): """THE SAFE-HARNESS PIN. A ``name`` key on the row — benign or @@ -439,9 +475,9 @@ class TestFormatIdleChildrenNudge: text = format_idle_children_nudge(children) bullet_rows = [ln for ln in text.splitlines() if ln.startswith(" - ")] assert bullet_rows == [ - " - ws-real0 (running)", - " - ws-evil0 (thinking)", - " - ws-real0 (attention)", + " - ws-real0001 (running)", + " - ws-evil0002 (thinking)", + " - ws-real0003 (attention)", ] # No name text, no fallback, no steering content anywhere. assert "benign-research" not in text @@ -451,13 +487,11 @@ class TestFormatIdleChildrenNudge: assert "(unnamed)" not in text def test_under_display_cap_no_overflow_line(self): - # The id's first 8 chars must differ per row, or the prefix - # assertions cannot tell the bullets apart. children = [{"ws_id": f"ws{i:02d}-aaaaaa", "state": "running"} for i in range(3)] text = format_idle_children_nudge(children) assert "...and" not in text for i in range(3): - assert f"ws{i:02d}-aaa" in text # the row's 8-char prefix + assert f" - ws{i:02d}-aaaaaa (running)" in text # the row's FULL id def test_over_display_cap_renders_overflow_line(self): n = NUDGE_IDLE_CHILDREN_DISPLAY_CAP + 4 @@ -470,7 +504,7 @@ class TestFormatIdleChildrenNudge: bullets = [ln for ln in text.splitlines() if ln.startswith(" - ")] assert len(bullets) == NUDGE_IDLE_CHILDREN_DISPLAY_CAP for i in range(NUDGE_IDLE_CHILDREN_DISPLAY_CAP): - assert f"ws{i:02d}-aaa" in bullets[i] + assert bullets[i] == f" - ws{i:02d}-aaaaaa (thinking)" def test_over_wait_cap_truncates_suggestion_ws_ids(self): n = NUDGE_IDLE_CHILDREN_WAIT_CAP + 5 @@ -673,8 +707,9 @@ class TestSanitizePayload: class TestFormatIdleTasksNudge: - """The ``idle_tasks`` body — counts opener, caveat, the open-id - block, typed branches with populated calls, and NOTHING else. + """The ``idle_tasks`` body — counts opener, per-child observed fact + lines, the open-id block, typed branches with populated calls, and + NOTHING else. Adopted off the round-8 numbers: the counts candidate matched or beat the roster body on every childless cell with zero forbidden @@ -687,20 +722,24 @@ class TestFormatIdleTasksNudge: and the controls here are the other half of that pair: ids and statuses appear, titles and notes cannot (the formatter is not given any), and every emitted call is populated from what the caller - observed rather than from an invented constant. + observed rather than from an invented constant. Children content is + the same discipline one step further (the 2026-07-29 ruling): the + caller observed ``(ws_id, state)`` per child, so the body renders + that fact per child and never a hedge about it. """ _OPEN = [("tsk_1", "in_progress"), ("tsk_2", "pending")] @staticmethod - def _fmt(counts=None, *, open_task_ids=None, child_ws_ids=None): - """Render the default two-open fixture with one live child. + def _fmt(counts=None, *, open_task_ids=None, children=None): + """Render the default two-open fixture with one running child. - ``child_ws_ids`` defaults to ``None`` — INDETERMINATE, which - keeps the caveat and populates no child slot — because that is - the value most assertions below want: the caveat-bearing body - with nothing about children fabricated into it. Tests that care - about population pass a list. + ``children`` defaults to one RUNNING child — the children-aware + body, which is the fuller of the two forms — because that is + what most assertions below want. Tests about the childless form + pass ``[]`` explicitly; there is no third state to default to + (the formatter takes a required list, and a failed production + read renders no body at all). The defaults live HERE and not on the formatter deliberately: a default on the production function would let a caller lose a @@ -712,7 +751,7 @@ class TestFormatIdleTasksNudge: open_task_ids=( TestFormatIdleTasksNudge._OPEN if open_task_ids is None else open_task_ids ), - child_ws_ids=child_ws_ids, + children=[("child-a", "running")] if children is None else children, ) def test_empty_counts_return_empty_string(self): @@ -776,26 +815,25 @@ class TestFormatIdleTasksNudge: " - tsk_2 (pending)", ] - def test_the_entire_body_is_counts_line_plus_tail(self): + def test_the_entire_body_is_counts_line_facts_plus_tail(self): """The strongest control: byte-equality against the one constant - plus the formatter-built opener, modulo exactly the slot - operations the formatter declares. + plus the formatter-built opening fact block, modulo exactly the + slot operations the formatter declares. Spelling the operations out here is the point — anything smuggled into the body (a provenance sentence, an interpolated title, a - second wait call) lands outside this equality and fails it, and - any slot operation that is NOT one of these has to be added to - this test before it can ship. + second wait call, a hedge sentence riding back in beside the + fact lines) lands outside this equality and fails it, and any + slot operation that is NOT one of these has to be added to this + test before it can ship. - Both branches of the children conditional are byte-checked, and - the childless one is TWO literal cuts against the same constant: - the caveat sentence and the whole blocked-on-a-child branch. - Deriving the childless body from the childful one by a single - caveat cut is what the shipped code did for one release, and it - is exactly the bug — so this test can no longer be written that - way either. + Both branches of the children conditional are byte-checked: the + children-bearing form is opener + one fact line per child + the + populated tail, and the childless one is the door's literal cut + against the same constant with NO fact lines. """ opener = "You still have 2 open tasks: 1 in_progress, 1 pending." + facts = chr(10) + "Child child-a is still running; check before redoing anything it owns." block = chr(10) * 2 + " - tsk_1 (in_progress)" + chr(10) + " - tsk_2 (pending)" childful = ( NUDGE_IDLE_TASKS_TAIL.replace(NUDGE_IDLE_TASKS_OPEN_LIST_SLOT, block, 1) @@ -803,15 +841,14 @@ class TestFormatIdleTasksNudge: .replace(NUDGE_IDLE_TASKS_ID_SLOT, "tsk_1") .replace(NUDGE_IDLE_TASKS_CHILD_SLOT, "child-a") ) - assert self._fmt(child_ws_ids=["child-a"]) == opener + childful + assert self._fmt(children=[("child-a", "running")]) == opener + facts + childful childless = ( - NUDGE_IDLE_TASKS_TAIL.replace(NUDGE_IDLE_TASKS_CHILDREN_CAVEAT, "", 1) - .replace(NUDGE_IDLE_TASKS_CHILD_DOOR, "", 1) + NUDGE_IDLE_TASKS_TAIL.replace(NUDGE_IDLE_TASKS_CHILD_DOOR, "", 1) .replace(NUDGE_IDLE_TASKS_OPEN_LIST_SLOT, block, 1) .replace(NUDGE_IDLE_TASKS_ID_SLOT, "tsk_1") ) - assert self._fmt(child_ws_ids=[]) == opener + childless + assert self._fmt(children=[]) == opener + childless def test_escape_branch_precedes_resume_branch(self): """Branch order follows harm: guessing on an operator decision is @@ -833,7 +870,7 @@ class TestFormatIdleTasksNudge: redoing a running child's work > a stale list > redone finished work. The blocked-on-child branch is the second escape hatch — after the operator one, before "take it".""" - out = self._fmt(child_ws_ids=["a1b2c3d4e5f6"]) + out = self._fmt(children=[("a1b2c3d4e5f6", "running")]) # The id in this slot must be a BARE HEX string, matching # uuid4().hex ids and the FE link regex /^[a-f0-9]{8,64}$/i — a # "ws_"-prefixed example taught the model an id shape @@ -851,53 +888,113 @@ class TestFormatIdleTasksNudge: assert out.index("needs_user") < out.index("child_ws_id=") assert out.index("child_ws_id=") < out.index("If the next step is yours") - def test_never_asserts_children_are_done(self): + def test_a_running_child_renders_the_running_fact_line(self): """This nudge can fire ALONE while children run (the liveness nudge can be blocked by its own cap or wait gate), so the - CHILDREN-PRESENT body must carry the may-still-be-running line — - deleting it reopens the resume-over-live-children hazard the old - cross-domain fire gate existed for. The childless body carries - no children claim at all, which asserts nothing false - (``test_childless_body_says_nothing_about_children_at_all``).""" - assert "may still be running" in self._fmt() + CHILDREN-PRESENT body must carry the check-before-redoing + protection — deleting it reopens the resume-over-live-children + hazard the old cross-domain fire gate existed for. It rides an + OBSERVED fact line, full id: the caller read the state this same + event, so the body states it rather than hedging about it.""" + out = self._fmt(children=[("child-a", "running")]) + assert ( + chr(10) + "Child child-a is still running; check before redoing anything it owns." + ) in out + + @pytest.mark.parametrize("state", ["thinking", "running", "attention"]) + def test_every_non_stopped_live_state_reads_as_still_running(self, state): + out = self._fmt(children=[("child-a", state)]) + assert "Child child-a is still running" in out + assert "has stopped" not in out + + @pytest.mark.parametrize("state", sorted(NUDGE_CHILD_STOPPED_STATES)) + def test_a_stopped_child_states_the_stop_and_the_immediate_wait(self, state): + """The stranded-child protection, per stopped state (idle AND + error — both are states ``wait_for_workstream`` treats as + terminal, so the line's wait-returns-immediately claim is true + for exactly this set). The line asserts the stop and the cheap + check, NOTHING about results: "may hold uncollected results" + was cut as a fabrication — no read observed results existing. + This line is the C6b protection — the old caveat's "may have + finished" disjunct, now attached to the child it is true of, + and the check it invites finds whatever is actually there.""" + out = self._fmt(children=[("child-a", state)]) + assert ( + chr(10) + "Child child-a has stopped — " + "wait_for_workstream returns immediately for it." + ) in out + assert "is still running" not in out.split(chr(10))[1] + + def test_mixed_children_render_one_fact_line_each_in_read_order(self): + out = self._fmt(children=[("child-a", "running"), ("child-b", "idle")]) + lines = out.split(chr(10)) + assert lines[1] == ( + "Child child-a is still running; check before redoing anything it owns." + ) + assert lines[2] == ( + "Child child-b has stopped — " + "wait_for_workstream returns immediately for it." + ) + + def test_no_hedge_survives_about_an_observed_state(self): + """MUTATION CONTROL for the retired caveat (ruled 2026-07-29): + the read returned each child's state, so "may still be running + or may have finished" was manufactured uncertainty, and "while + you worked" was manufactured context (the coordinator was IDLE). + Neither fabrication may ride back in under any children state. + """ + for children in ( + [], + [("child-a", "running")], + [("child-a", "idle")], + [("child-a", "running"), ("child-b", "idle")], + ): + out = self._fmt(children=children) + assert "may still be running" not in out, children + assert "may have finished" not in out, children + assert "while you worked" not in out, children + assert "Children of yours" not in out, children def test_childless_body_says_nothing_about_children_at_all(self): """An affirmative empty list — the caller observed no child in a - live state — removes EVERY children-bearing element: the caveat - sentence and the whole blocked-on-a-child branch. + live state — renders EVERY children-bearing element absent: no + fact lines and no blocked-on-a-child branch. - Removing only the sentence was the shipped behaviour for one - release, and it left the body omitting the claim about children - while keeping the instruction about children — two calls a - childless coordinator cannot make, pointing at a lookup that - returns nothing. Both cuts are literal-anchored against their own - constants, so a reworded neighbour cannot shift either. + Omitting the facts while keeping the instruction was the shipped + behaviour for one release, and it left a childless coordinator + reading two calls it could not make, pointing at a lookup that + returns nothing. The door cut is literal-anchored against its + own constant, so a reworded neighbour cannot shift it. Asserted first as ABSENCE OF THE TOPIC — no reworded branch could - satisfy it — and then as exact string differences, because a cut - that also took a neighbouring sentence, or that landed one - character off, would satisfy "the lines are gone" while shipping a - mangled paragraph. + satisfy it — and then as an exact string difference against the + children-bearing form, because a cut that also took a + neighbouring sentence, or that landed one character off, would + satisfy "the lines are gone" while shipping a mangled paragraph. """ - dropped = self._fmt(child_ws_ids=[]) + dropped = self._fmt(children=[]) - for absent in ("child", "wait_for_workstream", "list_workstreams", "may still be running"): + for absent in ("child", "Child", "wait_for_workstream", "list_workstreams"): assert absent not in dropped, absent - # The INDETERMINATE body minus exactly those two literals. The - # door's own ``task_id`` slot is already populated by the time it - # reaches a rendered body, so the literal cut against it has to be - # too — matching the constant raw here would silently no-op and - # this equality would then be comparing the wrong pair. - rendered_door = NUDGE_IDLE_TASKS_CHILD_DOOR.replace(NUDGE_IDLE_TASKS_ID_SLOT, "tsk_1") - hedged = self._fmt(child_ws_ids=None) - assert rendered_door in hedged - assert dropped == hedged.replace(NUDGE_IDLE_TASKS_CHILDREN_CAVEAT, "", 1).replace( - rendered_door, "", 1 + # The children-bearing body minus exactly the fact line and the + # rendered door. The door's own ``task_id`` slot is already + # populated by the time it reaches a rendered body, so the + # literal cut against it has to be too — matching the constant + # raw here would silently no-op and this equality would then be + # comparing the wrong pair. + rendered_door = ( + NUDGE_IDLE_TASKS_CHILD_DOOR.replace(NUDGE_IDLE_TASKS_ID_SLOT, "tsk_1") + .replace(NUDGE_IDLE_TASKS_WAIT_SLOT, wait_call(["child-a"]), 1) + .replace(NUDGE_IDLE_TASKS_CHILD_SLOT, "child-a") ) - # The opening paragraph the caveat cut leaves behind ends at the - # counts line it was appended to — the anchor the literal-replace - # rests on, and the whole reason the constant carries its own - # leading separator. + fact_line = ( + chr(10) + "Child child-a is still running; check before redoing anything it owns." + ) + childful = self._fmt(children=[("child-a", "running")]) + assert rendered_door in childful + assert dropped == childful.replace(fact_line, "", 1).replace(rendered_door, "", 1) + # The opening paragraph the fact-line cut leaves behind ends at + # the counts line the lines rode under. assert dropped.startswith( "You still have 2 open tasks: 1 in_progress, 1 pending." + chr(10) ) @@ -909,67 +1006,52 @@ class TestFormatIdleTasksNudge: assert kept_fragment in dropped, kept_fragment def test_populated_child_slots_are_the_other_half_of_the_read(self): - """One value carries the caveat, the branch AND the branch's two - slots, so a non-empty list moves all of them. + """One value carries the fact lines, the branch AND the branch's + two slots, so a non-empty list moves all of them. This is the deliberate consequence of not splitting the read into - a bool beside a list: in production every one of those answers one + two derivations: in production every one of those answers one storage question, and two derivations of one question are two - things that can disagree — which is precisely how the sentence - came to be conditional while the branch below it was not. + things that can disagree — which is precisely how the children + sentence once came to be conditional while the branch below it + was not. """ - populated = self._fmt(child_ws_ids=["child-a", "child-b"]) + populated = self._fmt(children=[("child-a", "running"), ("child-b", "idle")]) - assert NUDGE_IDLE_TASKS_CHILDREN_CAVEAT in populated + assert "Child child-a is still running" in populated + assert "Child child-b has stopped" in populated assert NUDGE_IDLE_TASKS_CHILD_SLOT not in populated # The SCALAR slot takes one id; the LIST slot takes them all, # because a list has no wrong element to pick. assert "child_ws_id='child-a'" in populated assert f" {wait_call(['child-a', 'child-b'])}" in populated - def test_caveat_rides_the_counts_line_as_its_second_sentence(self): - """The caveat's leading two-space separator makes it the second - sentence of the opening paragraph, exactly as it was the closing - sentence of the paragraph it used to ride.""" - kept = self._fmt(child_ws_ids=["child-a"]) + def test_fact_lines_ride_directly_under_the_counts_line(self): + """The opening fact block is one paragraph — the counts line + with the per-child lines directly beneath it, then the blank + line the tail's first element carries.""" + kept = self._fmt(children=[("child-a", "running")]) assert kept.startswith( - "You still have 2 open tasks: 1 in_progress, 1 pending. Children of yours" + "You still have 2 open tasks: 1 in_progress, 1 pending." + chr(10) + "Child child-a" ) - def test_indeterminate_children_keeps_the_caveat(self): - """``None`` is "the read failed", not "no children". - - The hedge is unconditionally true, so keeping it can only ever be - uninformative; omitting it turns a failed read into an implied - all-clear. Only an affirmative negative may select omission. - - A failed read also populates NOTHING: an id invented from an - unknown answer would put a call the model cannot run in front of - it, which is precisely the failure the ``a1b2c3d4`` constant used - to cause. - """ - hedged = self._fmt(child_ws_ids=None) - - assert "may still be running" in hedged - assert hedged.count(NUDGE_IDLE_TASKS_CHILD_SLOT) == 2 - assert f" {NUDGE_IDLE_TASKS_WAIT_SLOT}" in hedged - - def test_caveat_constant_is_the_tails_own_sentence(self): - """The constant and the tail are ONE sentence, not two copies. + def test_the_door_is_the_tails_own_literal(self): + """The constant and the tail are ONE text, not two copies. If the tail were reworded without the constant (or vice versa), the literal-anchored removal would silently no-op and every - childless body would ship the caveat again — a failure with no - symptom at the call site. + childless body would ship the children branch again — a failure + with no symptom at the call site. With the caveat sentence + retired for formatter-built fact lines, the door is the tail's + ONLY children-bearing element, so this is the whole conditional. """ - assert NUDGE_IDLE_TASKS_CHILDREN_CAVEAT in NUDGE_IDLE_TASKS_TAIL - assert NUDGE_IDLE_TASKS_TAIL.count(NUDGE_IDLE_TASKS_CHILDREN_CAVEAT) == 1 - # It carries its own leading sentence separator, so removing it - # cannot leave a double space or a hanging clause — the tail - # then opens directly on the branch paragraphs. - assert NUDGE_IDLE_TASKS_CHILDREN_CAVEAT.startswith(" ") - stripped = NUDGE_IDLE_TASKS_TAIL.replace(NUDGE_IDLE_TASKS_CHILDREN_CAVEAT, "", 1) - assert stripped.startswith(NUDGE_IDLE_TASKS_OPEN_LIST_SLOT) + assert NUDGE_IDLE_TASKS_CHILD_DOOR in NUDGE_IDLE_TASKS_TAIL + assert NUDGE_IDLE_TASKS_TAIL.count(NUDGE_IDLE_TASKS_CHILD_DOOR) == 1 + # The door carries its own leading blank line, so removing it + # cannot leave doubled spacing; and with no caveat in front of + # it the tail opens directly on the open-list slot. + assert NUDGE_IDLE_TASKS_CHILD_DOOR.startswith(chr(10) * 2) + assert NUDGE_IDLE_TASKS_TAIL.startswith(NUDGE_IDLE_TASKS_OPEN_LIST_SLOT) def test_every_slot_is_present_exactly_where_the_formatter_expects_it(self): """The slots and the tail are ONE text, not two copies — the same @@ -1023,7 +1105,7 @@ class TestFormatIdleTasksNudge: call = wait_call(ids) assert call in format_idle_children_nudge([{"ws_id": i, "state": "running"} for i in ids]) - assert call in self._fmt(child_ws_ids=ids) + assert call in self._fmt(children=[(ws_id, "running") for ws_id in ids]) def test_ids_populate_every_call_and_nothing_else_does(self): """The open ids ride in two places and only two: the block, and @@ -1067,6 +1149,30 @@ class TestFormatIdleTasksNudge: ] assert mixed.count("task_id='tsk_good'") == 3 + def test_a_hostile_status_drops_its_row_like_a_hostile_id(self): + """BOTH row fields take the alteration check, symmetrically: the + bullet interpolates the status beside the id, so a status the + strict sanitiser would alter is dropped with its row rather than + mangled into the body. Unreachable through the production + producer — ``_open_tasks`` vocabulary-filters statuses — so this + pins the formatter's PUBLIC surface, where ``open_task_ids`` is + caller-supplied. The counts stay the producer's mapping and are + untouched by the drop. + """ + newline = chr(10) + out = self._fmt( + open_task_ids=[ + ("tsk_bad", "pending" + newline + " - tsk_forged (done)"), + ("tsk_good", "pending"), + ] + ) + assert [ln for ln in out.split(newline) if ln.startswith(" - ")] == [ + " - tsk_good (pending)" + ] + assert "tsk_forged" not in out + assert out.count("task_id='tsk_good'") == 3 + assert out.startswith("You still have 2 open tasks: 1 in_progress, 1 pending.") + def test_no_system_reminder_envelope(self): """The body is raw text; the wire boundary folds it, not this.""" assert "system-reminder" not in self._fmt() diff --git a/turnstone/console/coordinator_idle_observer.py b/turnstone/console/coordinator_idle_observer.py index d64c6987..bc976125 100644 --- a/turnstone/console/coordinator_idle_observer.py +++ b/turnstone/console/coordinator_idle_observer.py @@ -23,16 +23,16 @@ event, and both nudges then fire and CO-DELIVER in one drain, tasks first — the grooming instruction is instant, the park instruction is open-ended, so the batch ends on the wait. Each nudge asserts only its own domain: the tasks body never claims the children are gone — it -carries the "children may still be running" caveat whenever any child -row exists in a live state, because the liveness nudge can be blocked -by its own cap or wait gate while advice fires alone, and on a -coordinator with no children it says nothing about children at all. -Either way a consistent pair is two true statements, not a -contradiction. One ordering caveat, accepted: a cross-bracket pair -(an older queued ``idle_children`` surviving into a bracket that -enqueues ``idle_tasks``) delivers children-first by seq; both entries -are still predicate-valid, so that is a tuning miss, not a correctness -one. If evals show small models fumbling even the ordered pair, the +renders one OBSERVED fact line per child row in a live state (still +running, or stopped with the wait's immediate return noted), because +the liveness nudge can be blocked by its own cap or wait gate while +advice fires alone, and on a coordinator with no children it says +nothing about children at all. Either way a consistent pair is two +true statements, not a contradiction. One ordering caveat, accepted: +a cross-bracket pair (an older queued ``idle_children`` surviving into +a bracket that enqueues ``idle_tasks``) delivers children-first by +seq; both entries are still predicate-valid, so that is a tuning miss, +not a correctness one. If evals show small models fumbling even the ordered pair, the named upgrade path is a single combined checkpoint type selected at produce time — do NOT reintroduce a cross-domain fire gate. @@ -78,10 +78,10 @@ Gate order, ``idle_tasks``: coordinator-kind → operator-Stop gate EVENT closed) → cap + cooldown peek → asked-operator skip → wait-tool skip → envelope read (corrupt → no fire; a FAILED read fails the EVENT closed) → -live-child id read (``[]``/ids → BODY CONTENT. It runs after every -gate it could otherwise perturb, and what it FINDS never changes -whether this path fires — only a read that FAILS does, and that -silences the event, not just this path) → permission check +live-child ``(ws_id, state)`` read (``[]``/rows → BODY CONTENT. It +runs after every gate it could otherwise perturb, and what it FINDS +never changes whether this path fires — only a read that FAILS does, +and that silences the event, not just this path) → permission check (``nudge_allowed``) → atomic charge → enqueue → record. ONE rule spans both paths, from the owner and unqualified: these @@ -158,11 +158,12 @@ _ACTIVE_CHILD_STATES: frozenset[str] = frozenset( # LIVE = the row still describes a child this coordinator has. A # different question from the one above — "can the model act on it?" — -# and the one the tasks body's children caveat speaks about, so it is a -# strict superset: ``idle`` is in it precisely because an idle child is -# the "may have finished while you worked" disjunct (a child that -# finished with results nobody collected), and ``error`` is in it -# because an errored child still owns the work it was given. +# and the one the tasks body's children fact lines speak about, so it +# is a strict superset: ``idle`` is in it precisely because an idle +# child may hold results nobody collected — the stopped-child fact +# line exists for exactly that row — and ``error`` is in it because an +# errored child still owns the work it was given (and is stopped in +# the same wait-terminal sense). # # Enum-derived rather than typed out. ``WorkstreamState`` is exactly # {idle, thinking, running, attention, error}, and the terminal strings @@ -446,11 +447,12 @@ class CoordinatorIdleObserver: put a ``list_workstreams`` round-trip on every coord state transition in the cluster. The tasks path never GATES on what children are FOUND — each nudge asserts only its own domain; - once its own gates have passed it reads one live-child id list - of its own, to fill in the body it plans, never to decide - whether to fire (:meth:`_live_child_ids_for_body`). Two paths, - two reads, and neither ANSWER is shared: the shape that would - tempt a shared read is the shape that re-couples the domains. + once its own gates have passed it reads one live-child + ``(ws_id, state)`` list of its own, to fill in the body it + plans, never to decide whether to fire + (:meth:`_live_children_for_body`). Two paths, two reads, and + neither ANSWER is shared: the shape that would tempt a shared + read is the shape that re-couples the domains. What the event does share is the failure signal — one failed read anywhere silences both classes — which keys on a read FAILING, never on what it found. @@ -729,8 +731,10 @@ class CoordinatorIdleObserver: # enum-derived state, neither of them user-authored text, so there # is no projection split here any more — the tasks path below keeps # its two-projection split because its fields ARE user-authored. - # The FE derives its ``ident`` column from ``ws_id`` (the same - # 8-char prefix the body's bullet carries). + # The FE derives its 8-char ``ident`` column from ``ws_id`` — + # display formatting over the same full id the body's bullet now + # carries whole (a bullet is a handle for the resolver, which + # refuses prefixes; an ident is a label for the operator's eye). children_meta = [ { "ws_id": c.get("ws_id", ""), @@ -792,13 +796,14 @@ class CoordinatorIdleObserver: same-event ``idle_children`` (co-delivery, tasks first) or alone while children run and the liveness nudge is blocked by its own cap or wait gate. The body never presumes the children are done - (its "children may still be running" caveat plus the - blocked-on-a-child branch are what make advice-alone honest - beside running children), and its drain predicate reads no - children at all. The one children read this path makes - (:meth:`_live_child_ids_for_body`) is the post-gate live-child id - list that selects between the children-aware body and the - childless one and populates the former's slots. + (its per-child observed fact lines plus the blocked-on-a-child + branch are what make advice-alone honest beside running + children), and its drain predicate reads no children at all. + The one children read this path makes + (:meth:`_live_children_for_body`) is the post-gate live-child + ``(ws_id, state)`` list that selects between the children-aware + body and the childless one, feeds the fact lines, and populates + the branch's slots. It is NOT true that no outcome of that read can suppress the fire, and the earlier absolute saying so was wrong. A read that @@ -1005,9 +1010,12 @@ class CoordinatorIdleObserver: # which buys more storage traffic, aimed at the thing that just # failed. Firing there adds load exactly where load is the # problem. The hedge's own justification also dissolves under - # the prior question: the caveat exists to keep the BODY safe - # when children are unknown, but not sending a body is safe - # too — strictly safer, and free. + # the prior question: a hedge exists to keep the BODY safe when + # children are unknown, but not sending a body is safe too — + # strictly safer, and free. (The same ruling later removed the + # hedge from the SUCCESSFUL-read body: the states are observed, + # so the body renders one fact line per child instead of "may + # still be running or may have finished".) # # BEFORE ANY CHARGE, structurally. Plans carry no side effects, # and ``_on_idle`` reaches :meth:`_commit_plan` — where @@ -1029,8 +1037,13 @@ class CoordinatorIdleObserver: # coord that gained a child inside that window — narrow, and # one-directional: a body with no children content asserts # NOTHING about children, while carrying it costs a measured - # ``list_workstreams`` round-trip on every childless bracket. A - # NEW out-of-band creator class (task-agent lanes) widens the + # ``list_workstreams`` round-trip on every childless bracket. + # A fact line can likewise outlive its state (a running child + # idles before delivery), and that lands safe in the same + # direction: the running line's instruction is check-before- + # redoing, which is exactly right for a child that has just + # finished, and an idle child does not restart. A NEW + # out-of-band creator class (task-agent lanes) widens the # window and must re-visit this condition. # # Cost: one ``list_workstreams`` fetch per IDLE event that clears @@ -1039,14 +1052,14 @@ class CoordinatorIdleObserver: # needed ids to populate its calls with; the rate is unchanged and # the per-read cost is now the liveness path's, which is the trade # the discovery round-trip it removes is worth (see - # :meth:`_live_child_ids_for_body`). - child_ws_ids = self._live_child_ids_for_body(ws) - if child_ws_ids is None: + # :meth:`_live_children_for_body`). + children = self._live_children_for_body(ws) + if children is None: raise _StorageReadError("live_children") text = format_idle_tasks_nudge( open_counts, open_task_ids=open_task_ids, - child_ws_ids=child_ws_ids, + children=children, ) # Bind identity by closure (never the live ``ws``). The @@ -1080,10 +1093,13 @@ class CoordinatorIdleObserver: # No children read, restated here because the temptation # recurs: an entry that outlives a bracket, survives a Stop # demotion, or is resurrected by a failed wake and then - # delivers beside live children is two true statements, not - # a contradiction — a caveat-bearing body covers that state - # explicitly, and a caveat-less one (enqueued when no live - # child row existed) asserts nothing about children at all. + # delivers beside live children stays two true-enough + # statements, not a contradiction — a children-bearing + # body's fact lines state what was observed at enqueue and + # their protections land safe under drift (a running child + # that idled still deserves check-before-redoing; an idle + # one does not restart), and a childless body asserts + # nothing about children at all. # # Corrupt envelope → drop, and a FAILED read → drop: a # failed storage read never delivers a nudge, at drain @@ -1156,7 +1172,7 @@ class CoordinatorIdleObserver: This read serves the LIVENESS path only, and nothing here may be made to license or block the ADVICE fire on what it FINDS — - advice makes its own read (:meth:`_live_child_ids_for_body`), + advice makes its own read (:meth:`_live_children_for_body`), after its own gates, and a found-children outcome that skipped an advice fire would be the re-coupling this warning forbids. A FAILED read is the one outcome that leaves this path's scope, @@ -1252,10 +1268,12 @@ class CoordinatorIdleObserver: param into the aggregate (protocol change) or accept that the divergence lands in the safe direction — liveness over-delivers, and the body's live-child read that shares this asymmetry - (:meth:`_live_child_ids_for_body`) over-reports children, which - keeps a hedge that is unconditionally true and adds a ws_id to a - ``mode="any"`` wait that returns on the first finisher anyway. - The advice DRAIN predicate reads no children at all. + (:meth:`_live_children_for_body`) over-includes rows, which adds + a fact line that is still TRUE of the row it names (the line + renders the row's own observed state; only its kind diverged) + and a ws_id to a ``mode="any"`` wait that returns on the first + finisher anyway. The advice DRAIN predicate reads no children + at all. """ try: counts = self._storage.count_workstreams_by_state( @@ -1271,19 +1289,22 @@ class CoordinatorIdleObserver: return None return any(counts.get(s, 0) > 0 for s in _ACTIVE_CHILD_STATES) - def _live_child_ids_for_body(self, ws: Workstream) -> list[str] | None: + def _live_children_for_body(self, ws: Workstream) -> list[tuple[str, str]] | None: """Enqueue-time answer to "which child rows of this coord are in - a live state?", for the ``idle_tasks`` BODY only. + a live state, and in which?", for the ``idle_tasks`` BODY only. - A (possibly empty) list of ``ws_id``\\ s on a successful read; - ``None`` when the read was INDETERMINATE (the query raised, or a - row was too ragged to classify). The THREE-WAY return is the - point, and each value has a different consumer: + A (possibly empty) list of ``(ws_id, state)`` pairs on a + successful read; ``None`` when the read was INDETERMINATE (the + query raised, or a row was too ragged to classify). The + THREE-WAY return is the point, and each value has a different + consumer: * ``[]`` and a NON-EMPTY list both reach :func:`~turnstone.core.metacognition.format_idle_tasks_nudge` and choose between the childless body and the children-aware - one. These are the only two values production ever hands it. + one. These are the only two values production ever hands it, + and since the formatter's children parameter became a required + list they are the only two it can express. * ``None`` never reaches the formatter at all: the caller raises it to the event level, before any charge, and NEITHER nudge is sent — a failed storage read fails the whole event closed @@ -1296,20 +1317,24 @@ class CoordinatorIdleObserver: both falsy and mean opposite things: one is an answer, the other is the absence of one. - It returns IDS rather than the bool it once did because the body - now populates two child slots from them (the blocked-on-a-child - branch's ``child_ws_id`` and its ``wait_for_workstream`` call). - One value serves the caveat and the slots deliberately: a bool - beside a list is two derivations of one storage read, and the - state where they disagree — hedge kept, slots empty, or worse the - reverse — is unreachable when there is only one value. + It returns ``(ws_id, state)`` PAIRS rather than the bare ids it + once did because the body renders the observed STATE per child + (the fact lines) as well as populating the blocked-on-a-child + branch's two slots. Bare ids forced the body to hedge about + states this very query had just read — "may still be running or + may have finished" — which is manufactured uncertainty, ruled + out 2026-07-29: the harness renders the fact it holds. One + value serves the fact lines and the slots deliberately: a + second derivation of the same storage read is a state where the + two can disagree, which is unreachable when there is only one + value. Deliberately :data:`_LIVE_CHILD_STATES`, not - :data:`_ACTIVE_CHILD_STATES`: the caveat speaks about children - that EXIST, and an idle child holding results nobody collected - is exactly its "may have finished while you worked" disjunct. - Conditioning on the active set would strip the hedge from the - one state whose protective disjunct is live. + :data:`_ACTIVE_CHILD_STATES`: the fact lines speak about + children that EXIST, and an idle child holding results nobody + collected is exactly what the stopped-child line protects. + Conditioning on the active set would drop the line from the one + state whose protection is live. COST, changed and stated: this was a ``count_workstreams_by_state`` aggregate while a bool was enough. Ids need rows, so it is now a @@ -1355,15 +1380,16 @@ class CoordinatorIdleObserver: parent_ws_id=ws.id, user_id=ws.user_id, ) - out: list[str] = [] + out: list[tuple[str, str]] = [] for row in rows: mapping = getattr(row, "_mapping", row) - if mapping["state"] not in _LIVE_CHILD_STATES: + state = mapping["state"] + if state not in _LIVE_CHILD_STATES: continue - out.append(mapping["ws_id"]) + out.append((mapping["ws_id"], state)) except Exception: log.debug( - "coord_idle_observer.caveat_list_failed ws=%s", + "coord_idle_observer.body_children_list_failed ws=%s", ws.id[:8], exc_info=True, ) diff --git a/turnstone/console/static/coordinator/coordinator.js b/turnstone/console/static/coordinator/coordinator.js index c7bac656..46ca56b0 100644 --- a/turnstone/console/static/coordinator/coordinator.js +++ b/turnstone/console/static/coordinator/coordinator.js @@ -1191,9 +1191,12 @@ function createCoordinatorPane(root, wsId, opts) { // vocabulary per surface-kind, not one rule for both cards. function appendIdleChildren(meta) { const children = Array.isArray(meta.children) ? meta.children : []; - // The ws_id prefix rides as `ident` — the same 8 chars the - // model-facing body's bullet carries — and is the row's identity - // column; a fresh row renders ident + state and nothing else. + // The ws_id's first 8 chars ride as `ident` — display formatting + // over the same full id the model-facing body's bullet carries + // whole (the bullet is a handle for the resolver, which refuses + // prefixes; this column is a label for the operator's eye) — and + // `ident` is the row's identity column; a fresh row renders + // ident + state and nothing else. const rows = children.map((c) => ({ ident: c && c.ws_id ? String(c.ws_id).slice(0, 8) : "", name: c && c.name ? String(c.name) : "", diff --git a/turnstone/core/metacognition.py b/turnstone/core/metacognition.py index 94754cbb..76053314 100644 --- a/turnstone/core/metacognition.py +++ b/turnstone/core/metacognition.py @@ -15,6 +15,9 @@ from __future__ import annotations import re import time +from typing import Any + +from turnstone.core.workstream import WorkstreamState # Default cooldown (s) between nudges of the same type. Production # paths pass ``cooldown_secs`` explicitly from @@ -243,8 +246,8 @@ def wait_call(ws_ids: list[str]) -> str: ``prompts/tools_coordinator.md`` uses) while ``mode`` is spelled with double quotes. Both are valid in the call syntax the model emits, and this exact byte sequence is what the roster has shipped and been - tuned against; re-quoting it is a body change and belongs in a sweep, - not in a refactor. + measured with; re-quoting it is a body change and belongs in a + sweep, not in a refactor. Callers cap *ws_ids* at :data:`NUDGE_IDLE_CHILDREN_WAIT_CAP` before calling — the cap is the executor's ``WAIT_MAX_WS_IDS``, so an @@ -253,7 +256,8 @@ def wait_call(ws_ids: list[str]) -> str: return f'wait_for_workstream(ws_ids={ws_ids!r}, mode="any", timeout=120)' -# The ``idle_children`` header states FACTS ONLY — no imperative. +# The ``idle_children`` header states FACTS ONLY — no imperative, and +# only the fact the drain predicate re-verifies. # # It carried "Either continue the user's work or block on the listed # children explicitly:" for most of this feature's life. Compressing @@ -263,20 +267,28 @@ def wait_call(ws_ids: list[str]) -> str: # advice body's first paragraph exists to deny. A liveness wake must # never be the thing that authorises continuing. # -# So the header now only reports the situation and the roster; the -# formatter's trailing line supplies the one actionable call +# It then opened with "You are idle." until 2026-07-29, and that +# sentence went for the honesty half of the same rule: a queued +# ``idle_children`` entry delivers at whichever seam arrives next, and +# its drain predicate re-verifies that CHILDREN are still active — +# never that the coordinator is still idle — so the harness was +# asserting a state it does not hold at delivery time. The body now +# opens with the drain-verified fact and nothing else. +# +# So the header only reports the roster's claim; the formatter's +# trailing line supplies the one actionable call # (``To block on them: wait_for_workstream(...)``). Deciding what to # do with a live child is the model's call on the evidence, not this # message's to grant. -NUDGE_IDLE_CHILDREN_HEADER = "You are idle. These child workstreams are still active:" +NUDGE_IDLE_CHILDREN_HEADER = "These child workstreams are still active:" -# The ``idle_tasks`` body. TUNED AGAINST THE BEHAVIORAL EVAL -# (``turnstone-eval --nudges``) — deepseek-v4-flash and qwen3.6-27B. -# Reword freely, but re-run the eval: every property below is here -# because measuring it MOVED the numbers (or removed a paragraph that -# measured as carrying nothing), and the whole point of the body is -# behavioural, not stylistic. +# The ``idle_tasks`` body. Under active development, MEASURED AGAINST +# THE BEHAVIORAL EVAL (``turnstone-eval --nudges``) — deepseek-v4-flash +# and qwen3.6-27B so far. Reword freely, but re-run the eval: every +# property below is here because measuring it MOVED the numbers (or +# removed a paragraph that measured as carrying nothing), and the whole +# point of the body is behavioural, not stylistic. # # Four properties are load-bearing: # @@ -346,44 +358,46 @@ NUDGE_IDLE_CHILDREN_HEADER = "You are idle. These child workstreams are still a # branch-scope confusion — the model reading "is this done?" as a # judgement only the user may make — NOT missing permission; a # bare "you may mark it done" clause moved nothing. -# 4. It never asserts the children are gone, and it never instructs -# about children it knows are absent. ONE observed fact governs -# BOTH children-bearing elements — the caveat sentence and the +# 4. Children content is OBSERVED FACT, never hedge, and the body +# never instructs about children it knows are absent. ONE observed +# read governs BOTH children-bearing elements — the per-child fact +# lines the formatter builds under the counts line and the # blocked-on-a-child branch — because they are one claim in two # registers, and splitting them produced exactly the contradiction -# you would predict: for one release the sentence was correctly -# omitted for a childless coordinator while the branch below it -# still said "if an item is waiting on a child workstream still -# running", followed by two calls that coordinator could not make. -# This nudge can co-deliver beside an ``idle_children`` wake (tasks -# first) or fire ALONE while children run — the liveness nudge can -# be blocked by its own cap or wait gate — so both elements are -# carried whenever any child row exists in a live state: the body -# never asserts children are gone, and the hedge it does carry is -# unconditionally true. (A FAILED children read renders no body -# at all — the observer fails its whole event closed.) -# Deleting either from the CHILDREN-PRESENT body reopens the +# you would predict: for one release the children sentence was +# correctly omitted for a childless coordinator while the branch +# below it still said "if an item is waiting on a child workstream +# still running", followed by two calls that coordinator could not +# make. This nudge can co-deliver beside an ``idle_children`` wake +# (tasks first) or fire ALONE while children run — the liveness +# nudge can be blocked by its own cap or wait gate — so the fact +# lines and the branch are carried whenever any child row exists in +# a live state. (A FAILED children read renders no body at all — +# the observer fails its whole event closed.) +# The fact lines replaced a hedged caveat sentence ("Children of +# yours may still be running or may have finished while you +# worked...") on a 2026-07-29 ruling: the harness must never render +# manufactured uncertainty or manufactured context when it holds +# the observed fact. "may still be running or may have finished" +# hedged states the producer's read had JUST RETURNED, and "while +# you worked" invented activity for a coordinator that was idle. +# The producer threads ``(ws_id, state)`` per child and the +# formatter renders that fact and nothing more: a running child is +# reported running (check before redoing what it owns), a stopped +# one is reported stopped (``wait_for_workstream`` returns +# immediately for it) — the protective points the hedge carried, +# each now attached to the child it is true of, and nothing about +# results, whose existence no read observed. Deleting either children-bearing element +# from the CHILDREN-PRESENT body reopens the # resume-over-live-children hazard the old cross-domain fire gate # existed for. On a coordinator with no children both are measured -# noise (round-5 sweep: the sentence induces a ``list_workstreams`` -# round-trip that is the entire nudge-arm failure mode on the -# childless cells) and both are omitted — an omission asserts -# nothing about children at all. A body with NOTHING about children -# in it is also what makes the eval's ablation arm a clean -# single-factor question: does this body need to mention children? -# -# The caveat is its own constant so the omission is a LITERAL-anchored -# removal rather than a positional cut, and so the two forms of the body -# cannot drift apart: there is one sentence, spliced in here and spliced -# out by :func:`format_idle_tasks_nudge`. It carries its own leading -# two-space sentence separator, so it reads as the second sentence of -# the counts line it now rides, and removing it leaves "…N open -# task(s): …\n" — the opening paragraph both forms of the body share. -NUDGE_IDLE_TASKS_CHILDREN_CAVEAT = ( - " Children of yours may still be running or may have finished " - "while you worked; check before redoing anything a child owns — " - "wait_for_workstream returns immediately for a finished child." -) +# noise (round-5 sweep: children prose induces a +# ``list_workstreams`` round-trip that is the entire nudge-arm +# failure mode on the childless cells) and both are omitted — an +# omission asserts nothing about children at all. A body with +# NOTHING about children in it is also what makes the eval's +# ablation arm a clean single-factor question: does this body need +# to mention children? # THE SLOTS. Each is a literal that the shipped tail contains and # :func:`format_idle_tasks_nudge` substitutes at render time, exactly as @@ -410,12 +424,14 @@ NUDGE_IDLE_TASKS_CHILDREN_CAVEAT = ( # honest recovery — the harness holds no id and says so. # * :data:`NUDGE_IDLE_TASKS_CHILD_SLOT` is a pure TEMPLATE VARIABLE. # No production body can contain it. The branch it sits in renders -# only when the caller passed live child ids, and it is substituted -# whenever it renders; the states that would leave it unsubstituted -# are an INDETERMINATE children read (the observer now declines to -# fire at all, so the formatter never sees it) and a live child row -# with an empty ``ws_id`` (not producible — every creation path mints -# through ``uuid4().hex`` or ``secrets.token_hex``). +# only when the caller passed child rows, and it is substituted +# whenever it renders; the one state that would leave it +# unsubstituted is a live child row with an empty ``ws_id`` (not +# producible — every creation path mints through ``uuid4().hex`` or +# ``secrets.token_hex``). The INDETERMINATE-read state that once +# rendered it is no longer even expressible: the formatter takes a +# required children list, and a failed read renders no body at all +# (the observer fails its whole event closed). # # The constant therefore stays for a STRUCTURAL reason and not a # defensive one: the branch lives inside :data:`NUDGE_IDLE_TASKS_TAIL`, @@ -445,31 +461,24 @@ NUDGE_IDLE_TASKS_OPEN_LIST_SLOT = "\n\n - str: where embedded newlines would forge a fake sibling row and angle brackets would steer the reasoning channel. - NEITHER idle formatter calls this any more — - :func:`format_idle_children_nudge` is ids-and-states only, and - :func:`format_idle_tasks_nudge` is counts-and-branches only, both - all server-minted — but this function is the mandatory route for - any model- or user-authored field either body ever grows (the - belt-and-braces rule in both formatters' docstrings), which is why - it stays although no production path currently calls it. + NEITHER idle formatter interpolates a sanitised projection any + more — :func:`format_idle_children_nudge` is ids-and-states only, + and :func:`format_idle_tasks_nudge` is counts, ids, states and + branches only, all server-minted. The one remaining call is + :func:`format_idle_tasks_nudge` using this as an ALTERATION CHECK + over its open-row fields (a value this function would change is + dropped, never mangled into the body). This function stays the + mandatory route for any model- or user-authored field either body + ever grows (the belt-and-braces rule in both formatters' + docstrings). """ if not text: return "" @@ -662,19 +694,34 @@ def sanitize_display(text: str) -> str: return _NAME_CONTROL_CHARS.sub(" ", text).strip() -def format_idle_children_nudge(children: list[dict[str, str]]) -> str: +def format_idle_children_nudge(children: list[dict[str, Any]]) -> str: """Render the ``idle_children`` reminder body — ids and states ONLY. *children* is a list of dicts carrying at least ``ws_id`` and - ``state`` — the row-mapping shape coordinator-side storage exposes. - Returns raw text *without* any envelope; the nudge is emitted as a - first-class ``{"role": "system"}`` turn whose content is this text - (folded to a ``[start system-reminder]`` block at the wire boundary on - non-native models). + ``state`` as strings — the observer's row projection and the eval's + fixture projection both satisfy it; every OTHER key a row carries + (``name`` above all) is ignored, which is why the annotation is + ``dict[str, Any]`` rather than a narrower shape that only the + projections happen to hold. Returns raw text *without* any + envelope; the nudge is emitted as a first-class + ``{"role": "system"}`` turn whose content is this text (folded to a + ``[start system-reminder]`` block at the wire boundary on non-native + models). - Every interpolated value is SERVER-MINTED: the 8-char ``ws_id`` - prefix per row, the enum-derived state, and the full ``ws_id``\\ s in - the trailing ``wait_for_workstream`` suggestion. Child NAMES are + Bullets carry the FULL ``ws_id``. The roster's documented purpose + is to hand the model HANDLES it can act on — inspect, message, wait + — and ``CoordinatorClient._resolve_ws_ref`` refuses truncated ids by + design (near-miss ids are NEVER auto-resolved), so an 8-char prefix + here was not a handle: a model that copied a bullet issued a call + the resolver rejects, while the full id sat one line down in the + wait suggestion. Bullet and wait line now carry the same full id. + Prefixing for READABILITY is a display concern and lives where + display belongs — the FE derives its 8-char ident from the card + metadata's full ``ws_id``. + + Every interpolated value is SERVER-MINTED: the full ``ws_id`` and + the enum-derived state per row, and the full ``ws_id``\\ s in the + trailing ``wait_for_workstream`` suggestion. Child NAMES are model-authored (a coordinator names its children when it spawns them) and are deliberately NOT rendered — interpolating them lowered the plant's own output back into a trusted system turn, where a @@ -703,7 +750,7 @@ def format_idle_children_nudge(children: list[dict[str, str]]) -> str: for c in shown: ws_id = c.get("ws_id", "") state = c.get("state", "?") - lines.append(f" - {ws_id[:8]} ({state})") + lines.append(f" - {ws_id} ({state})") overflow = len(children) - len(shown) if overflow > 0: lines.append(f" ...and {overflow} more") @@ -791,22 +838,24 @@ def format_idle_tasks_nudge( open_counts: dict[str, int], *, open_task_ids: list[tuple[str, str]], - child_ws_ids: list[str] | None, + children: list[tuple[str, str]], ) -> str: - """Render the ``idle_tasks`` reminder body — counts, open IDS and - typed branches, NO task text. + """Render the ``idle_tasks`` reminder body — counts, open IDS, + per-child fact lines and typed branches, NO task text. - The opener is one counts line ("You still have N open task(s): X - in_progress, Y pending"), and that line IS the situation statement: - the provenance paragraph that used to carry it was pruned on the - round-8 numbers (its ablation showed no isolated effect on the - correct wire), and the TITLES the roster carried went with it (the - counts candidate matched or beat the roster on every childless cell - with zero forbidden actions). The rest of the body is - :data:`NUDGE_IDLE_TASKS_TAIL` — the conditional children caveat, the - open-id block, and the typed branches, one of which (the - blocked-on-a-child one) is itself conditional on the same fact the - caveat is. + The opening fact block is FORMATTER-BUILT: one counts line ("You + still have N open task(s): X in_progress, Y pending") followed by + one observed-fact line per child row (below). The counts line IS + the situation statement: the provenance paragraph that used to carry + it was pruned on the round-8 numbers (its ablation showed no + isolated effect on the correct wire), and the TITLES the roster + carried went with it (the counts candidate matched or beat the + roster on every childless cell with zero forbidden actions). The + rest of the body is :data:`NUDGE_IDLE_TASKS_TAIL` — the open-id + block and the typed branches, one of which (the blocked-on-a-child + one) is conditional on the same children rows the fact lines render. + Facts are harness-rendered and never part of the overridable tail; + the tail carries the typed branches. *open_counts* maps each OPEN task status to how many of the coordinator's tasks hold it. The producer derives it from its own @@ -828,21 +877,27 @@ def format_idle_tasks_nudge( association an id needs is already in its transcript (``tasks(action='add')`` answers with id AND title). - A row is USABLE only when its id is non-empty and survives - :func:`sanitize_name` unchanged. Task ids are server-minted at the - write path (``tsk_`` + ``secrets.token_hex``) but are read back out - of a JSON blob that a hand-edited DB or an older writer can leave - ragged, so this is the belt-and-braces route applied where it can - also be honest about executability: an id the sanitiser would ALTER - is dropped rather than mangled, because a mangled id renders a call - that cannot resolve — the precise failure the invented - ``child_ws_id='a1b2c3d4'`` constant used to cause. With no usable - row the block is removed and the branches keep - :data:`NUDGE_IDLE_TASKS_ID_SLOT` — one of the two states in which - this body still asks for a discovery round-trip (the other is the - eval override's cut-nothing ``None``; production's failed read no - longer renders a body at all), and in both the round-trip is the - honest answer because the harness genuinely holds no usable id. + A row is USABLE only when its id is non-empty and BOTH its fields — + id and status — survive :func:`sanitize_name` unchanged. Task ids + are server-minted at the write path (``tsk_`` + + ``secrets.token_hex``) and statuses are vocabulary-checked there, + but both are read back out of a JSON blob that a hand-edited DB or + an older writer can leave ragged, so this is the belt-and-braces + route applied where it can also be honest about executability: a + value the sanitiser would ALTER drops its row rather than being + mangled into the body — a mangled id renders a call that cannot + resolve, the precise failure the invented ``child_ws_id='a1b2c3d4'`` + constant used to cause, and a mangled status would misreport the + stored state. (Through the production producer the status half is + inert: ``CoordinatorIdleObserver._open_tasks`` already drops any row + whose status is outside ``TASK_OPEN_STATUSES``. The guard is for + this function's PUBLIC surface, where ``open_task_ids`` is + caller-supplied.) With no usable row the block is removed and the + branches keep :data:`NUDGE_IDLE_TASKS_ID_SLOT` — the ONE state in + which this body still asks for a discovery round-trip (production's + failed read no longer renders a body at all), and there the + round-trip is the honest answer because the harness genuinely holds + no usable id. The single branch example prefers an ``in_progress`` row, falling back to the first usable one. It is one id across every branch call @@ -851,71 +906,90 @@ def format_idle_tasks_nudge( while a per-branch id would read as the harness ruling which row is done. - *child_ws_ids* is keyword-only and required, and it is the SINGLE - value governing every children-bearing element of this body — the - caveat sentence, the blocked-on-a-child branch, and that branch's two - slots. One value because one storage read answers all of them, and - because a body that omits the sentence about children while keeping - the instruction about children is a contradiction the reader has to - resolve. The trichotomy is exactly the one the caller's read - produces: + *children* is keyword-only and REQUIRED — a list of + ``(ws_id, state)`` pairs, the exact projection both callers hold + (the observer's ``_live_children_for_body`` over live-state rows and + the eval's ``_body_children`` over fixture rows) — and it is the + SINGLE value governing every children-bearing element of this body: + the per-child fact lines, the blocked-on-a-child branch, and that + branch's two slots. One value because one storage read answers all + of them, and because a body that renders facts about children while + omitting the instruction about them (or the reverse) is a + contradiction the reader has to resolve. Two states, exactly the + two the caller's read produces: * ``[]`` — an affirmative "this coordinator has no child row in a - live state" — removes :data:`NUDGE_IDLE_TASKS_CHILDREN_CAVEAT` AND - :data:`NUDGE_IDLE_TASKS_CHILD_DOOR`, both by literal match rather - than by position, so a reworded neighbour cannot shift either cut. - Nothing about children survives: no sentence, no branch, no + live state" — renders no fact lines and removes + :data:`NUDGE_IDLE_TASKS_CHILD_DOOR` by literal match rather than + by position, so a reworded neighbour cannot shift the cut. + Nothing about children survives: no facts, no branch, no placeholder pointing at a lookup that is known to return nothing. - * a NON-EMPTY list keeps both and populates the branch: the wait call - takes every id (capped at :data:`NUDGE_IDLE_CHILDREN_WAIT_CAP`, - ``mode="any"``), which is unambiguous because a list slot has no - wrong element to pick, and ``child_ws_id`` takes the first — most - recently updated, since the caller's query orders by - ``updated DESC``. - * ``None`` — "I am not asserting a children state" — keeps both and - substitutes neither slot. PRODUCTION NEVER PASSES THIS. The - observer used to, for an indeterminate read, and now fails its - whole event closed instead — a failed storage read silences BOTH - idle nudges before either cap is charged: a nudge fired off a - query that just failed is a reminder we cannot substantiate, - aimed at a backend that is telling us it is unwell. Not sending - is as safe as hedging and strictly cheaper, so the hedge lost its - reason. + * a NON-EMPTY list renders ONE FACT LINE PER CHILD under the counts + line and populates the branch. Each line states the observed + fact and nothing more — the states are the caller's read, taken + this same event, so hedging them would be manufactured + uncertainty: - The branch stays because it has a live caller: - ``turnstone.eval.nudges.render_tasks_body`` passes it under - ``--body-override``, where the tail is operator-authored candidate - text and BOTH literal cuts must be suppressed so a candidate that - quotes the shipped caveat verbatim is not silently mauled. ``None`` - is the only input that says "cut nothing" without also inventing a - ws_id to substitute. If that caller ever goes, so should this - branch — it is not kept for safety. + - a child whose state is outside + :data:`NUDGE_CHILD_STOPPED_STATES` (thinking / running / + attention today) is STILL RUNNING; the line says so and carries + the check-before-redoing protection. + - a child in :data:`NUDGE_CHILD_STOPPED_STATES` (idle / error) + has STOPPED, and the line carries the wait-returns-immediately + point, true for exactly those states (they are the wait's live + terminal states). It asserts NOTHING about results: "may hold + uncollected results" was cut as a fabrication (2026-07-29) — + "results" is a noun the read never observed, and "uncollected" + implies a collection ledger nobody consulted; hedging an entity + into existence with "may" is still fabrication. The immediate + wait is the whole protection: checking is cheap, and whatever + the child did or did not produce is what the check finds. - Populating the child slots asserts nothing about the TASK: it fills - the only values a call there could take. Whether any item is in - fact waiting on a child stays the model's judgement, which is why - the branch is still a conditional sentence. + The branch's wait call takes every id (capped at + :data:`NUDGE_IDLE_CHILDREN_WAIT_CAP`, ``mode="any"``), which is + unambiguous because a list slot has no wrong element to pick, and + ``child_ws_id`` takes the first — most recently updated, since + the caller's query orders by ``updated DESC``. - NO SANITISER runs on the counts, the statuses or the ws_ids: the - counts are integers, the statuses are the producer's own - ``TASK_OPEN_STATUSES`` vocabulary, and the ws_ids come from the - workstreams table's primary key — the same server-minted source - :func:`format_idle_children_nudge` already interpolates raw into its - wait call, so a stricter rule here would be an asymmetry with no - reason behind it. Task ids take the sanitiser as an - ALTERATION CHECK (above) because their store is softer. - BELT-AND-BRACES RULE (the :func:`format_idle_children_nudge` - precedent): any model- or user-authored field this body ever grows - MUST go back through :func:`sanitize_name` before interpolation — - server-minted provenance is the only exemption. + ``None`` is NOT an input any more. The old "I am not asserting a + children state" hedge branch lost production when the observer began + failing its whole event closed on an indeterminate read, and lost + its last caller (the eval override's cut-nothing mapping) when the + override sweep started refusing childless cells at config time — so + it died with the rule its own docstring predicted it would. A + failed read renders no body at all; there is nothing left for a + hedge to cover. + + Fact lines and slot population assert nothing about the TASKS: they + state each child's observed state and fill the only values a call + there could take. Whether any item is in fact waiting on a child + stays the model's judgement, which is why the branch is still a + conditional sentence. Ids render FULL — the resolver refuses + truncated ids by design, and a fact line's purpose is to pair a + usable handle with the fact about it. + + NO SANITISER runs on the counts line or the children values: the + counts are integers, the counts line's status terms are the + producer's own mapping over ``TASK_OPEN_STATUSES`` (never row data), + and the child ws_ids and states come from the workstreams table's + primary key and the state enum — the same server-minted sources + :func:`format_idle_children_nudge` already interpolates raw, so a + stricter rule here would be an asymmetry with no reason behind it. + The open-row fields — id AND status — take the sanitiser as an + ALTERATION CHECK (above) because their store, the task envelope + blob, is softer. BELT-AND-BRACES RULE (the + :func:`format_idle_children_nudge` precedent): any model- or + user-authored field this body ever grows MUST go back through + :func:`sanitize_name` before interpolation — server-minted + provenance is the only exemption. COMPOSITION CAVEAT, carried where the next editor will see it: counts-without-provenance was never measured as a single body — the counts candidate and the provenance ablation each measured clean independently — and the round-9 nudge arm is the confirmation of this composition, since the eval renders production - bodies by construction. The id block and the populated calls are - NOT yet swept either. + bodies by construction. The id block, the populated calls and the + per-child fact lines are NOT yet swept either. Returns raw text *without* any envelope, matching :func:`format_idle_children_nudge` — the nudge is emitted as a @@ -928,19 +1002,24 @@ def format_idle_tasks_nudge( return "" tail = NUDGE_IDLE_TASKS_TAIL - # ``== []``, never a truthiness test: ``None`` is the indeterminate - # read and must keep both (see the docstring). - # - # ONE condition, BOTH children-bearing elements. The caveat sentence - # and the blocked-on-a-child branch are removed together or kept - # together, because a body that omits the sentence about children - # while keeping the instruction about children is the defect the - # sentence's conditional exists to fix, one block lower down. - if child_ws_ids == []: - tail = tail.replace(NUDGE_IDLE_TASKS_CHILDREN_CAVEAT, "", 1) + # ONE condition, BOTH children-bearing elements. The per-child fact + # lines (built below, beside the opener) and the blocked-on-a-child + # branch are removed together or kept together, because a body that + # omits the facts about children while keeping the instruction about + # children is the defect this conditional exists to fix, one block + # lower down. The cut is literal-anchored, never positional. + if not children: tail = tail.replace(NUDGE_IDLE_TASKS_CHILD_DOOR, "", 1) - usable = [(tid, status) for tid, status in open_task_ids if tid and sanitize_name(tid) == tid] + # BOTH row fields take the alteration check — see the docstring's + # USABLE rule. Through the production producer the status half is + # inert (``_open_tasks`` vocabulary-filters rows), so this guards the + # public surface only. + usable = [ + (tid, status) + for tid, status in open_task_ids + if tid and sanitize_name(tid) == tid and sanitize_name(status) == status + ] if usable: block = "\n".join(f" - {tid} ({status})" for tid, status in usable) tail = tail.replace(NUDGE_IDLE_TASKS_OPEN_LIST_SLOT, f"\n\n{block}", 1) @@ -952,31 +1031,48 @@ def format_idle_tasks_nudge( else: tail = tail.replace(NUDGE_IDLE_TASKS_OPEN_LIST_SLOT, "", 1) - # A falsy ws_id would render ``child_ws_id=''`` — populated in - # appearance and unrunnable in fact, which is the shape this whole - # change exists to remove. Not producible today (every creation path - # mints through ``uuid4().hex`` or ``secrets.token_hex``), so this is - # a guard against a future writer rather than a live branch; it is one - # comprehension and it keeps "substituted ⇒ runnable" true by - # construction rather than by an argument about id minting three - # modules away. Filtered for RENDERING only: the branch cut above - # read the raw list, so a child row that EXISTS without a usable id - # still keeps the branch. - live = [ws_id for ws_id in (child_ws_ids or []) if ws_id] + # A falsy ws_id would render ``child_ws_id=''`` and a fact line with + # no handle — populated in appearance and unrunnable in fact, which + # is the shape this whole feature exists to remove. Not producible + # today (every creation path mints through ``uuid4().hex`` or + # ``secrets.token_hex``), so this is a guard against a future writer + # rather than a live branch; it is one comprehension and it keeps + # "rendered ⇒ actionable" true by construction rather than by an + # argument about id minting three modules away. Filtered for + # RENDERING only: the branch cut above read the raw list, so a child + # row that EXISTS without a usable id still keeps the branch. + live = [(ws_id, state) for ws_id, state in children if ws_id] if live: # The wait call FIRST: its slot literal contains the child slot, # so substituting the scalar first would consume the bytes this # replace matches on and strand the prose call. tail = tail.replace( NUDGE_IDLE_TASKS_WAIT_SLOT, - wait_call(live[:NUDGE_IDLE_CHILDREN_WAIT_CAP]), + wait_call([ws_id for ws_id, _state in live[:NUDGE_IDLE_CHILDREN_WAIT_CAP]]), 1, ) - tail = tail.replace(NUDGE_IDLE_TASKS_CHILD_SLOT, live[0]) + tail = tail.replace(NUDGE_IDLE_TASKS_CHILD_SLOT, live[0][0]) + + # The per-child fact lines — FORMATTER-BUILT, like the counts opener, + # so no override and no tail rewording can reach them: facts are + # harness-rendered, the tail carries the typed branches. Each line + # states the observed fact (the caller's read, this same event) and + # pairs it with the protection that is true FOR THAT STATE — no + # "may" about a state the read returned, no invented context. The + # one "may" that remains is honest: whether a stopped child's + # results were collected is the thing the read did not observe. + facts = "".join( + ( + f"\nChild {ws_id} has stopped — wait_for_workstream returns immediately for it." + if state in NUDGE_CHILD_STOPPED_STATES + else f"\nChild {ws_id} is still running; check before redoing anything it owns." + ) + for ws_id, state in live + ) split = ", ".join(f"{open_counts[status]} {status}" for status in sorted(open_counts)) noun = "task" if total == 1 else "tasks" - return f"You still have {total} open {noun}: {split}.{tail}" + return f"You still have {total} open {noun}: {split}.{facts}{tail}" # --------------------------------------------------------------------------- diff --git a/turnstone/eval/cli.py b/turnstone/eval/cli.py index b012cd37..548829f2 100644 --- a/turnstone/eval/cli.py +++ b/turnstone/eval/cli.py @@ -263,11 +263,16 @@ def main() -> None: default=None, help=( "--nudges: path to a file whose content replaces " - "NUDGE_IDLE_TASKS_TAIL — the caveat and typed branches; the " - "counts opener is formatter-built — for this sweep (tuning " - "A/B only; the default is always the production body). " - "Skips the no_caveat arm, whose ablation only means " - "anything against the body that ships" + "NUDGE_IDLE_TASKS_TAIL — the open-id block and typed " + "branches; the counts opener AND the per-child children " + "fact lines are formatter-built from seeded state, never " + "override text — for this sweep (candidate A/B only; the " + "default is always the production body). Skips the " + "no_caveat arm, whose ablation only means anything against " + "the body that ships, and REFUSES childless cells at config " + "time: both select the childless branch, whose literal " + "door cut would silently strip a candidate that quotes the " + "shipped blocked-on-a-child branch" ), ) parser.add_argument( diff --git a/turnstone/eval/nudges.py b/turnstone/eval/nudges.py index d7292ac1..eb5ed119 100644 --- a/turnstone/eval/nudges.py +++ b/turnstone/eval/nudges.py @@ -156,19 +156,21 @@ _PAIR_ARMS: frozenset[str] = frozenset({ARM_PAIR_TF}) # (:func:`_check_body_arms_have_an_open_task`). _TASKS_BODY_ARMS: frozenset[str] = KNOWN_ARMS - {ARM_BARE_CONTINUE} -# Why :data:`ARM_NO_CAVEAT` cannot run under ``--body-override``. Its -# ablation is literal-anchored, so it does not maul unknown text — it -# silently does nothing to a candidate that reworded the sentence, and -# something worse to the likeliest candidate shape of all: one that -# keeps the caveat verbatim while editing another paragraph still -# contains the literal, so the cut would land and the sweep would file -# a caveat-stripped candidate under the tuning heading. The remedy: an -# override measures candidate text exactly as authored -# (:func:`render_tasks_body`'s ``override_active``), so the one arm -# whose whole definition is "the other branch of the conditional" has -# nothing to measure while an override is in play. The cut now takes -# the blocked-on-a-child BRANCH as well as the sentence, which widens -# what a mauled candidate would lose without changing the reasoning. +# Why :data:`ARM_NO_CAVEAT` cannot run under ``--body-override``. The +# arm renders the formatter's CHILDLESS branch, and that branch cuts the +# blocked-on-a-child door out of the tail by literal match — an +# operation defined against the shipped text. Against candidate text +# the cut does not maul unknown wording — it silently does nothing — but +# the likeliest candidate shape of all, one that keeps the shipped door +# verbatim while editing another paragraph, still contains the literal, +# so the cut would land and the sweep would file a door-stripped +# candidate under the sweep's heading. The structural remedy is +# two-part: childless CELLS are refused at config time under an +# override (:func:`_check_override_cells_have_a_live_child`), so no cell +# STATE can select the childless branch — and this skip closes the one +# route left, the arm whose whole definition IS that branch. (The +# children fact lines and the counts opener are formatter-built from +# seeded state, so an override can never touch them either way.) _NO_CAVEAT_SKIP_REASON = "no_caveat measures the production body's childless branch only" # The arms a ``--body-override`` sweep reports as skipped rather than @@ -375,8 +377,13 @@ class _StubCoordinatorClient(CoordinatorClient): return {"error": f"{url.rsplit('/', 1)[-1]}: unavailable in the eval environment"} def spawn(self, **kw: Any) -> dict[str, Any]: + # The fallback ws_id is production-shaped (32 lowercase hex, the + # resolver's hot-path shape) so a run that inspects or waits on + # its own spawn result exercises the same resolution path a real + # coordinator's would — the old ``ws_stub_spawn01`` shape was + # rejected by ``_resolve_ws_ref`` on every follow-up call. return self._scripted("spawn") or { - "ws_id": "ws_stub_spawn01", + "ws_id": "0added000added000added000added00", "state": "running", "name": str(kw.get("name") or "child"), } @@ -548,8 +555,7 @@ class CoordinatorHeadlessSession(HeadlessSession): def render_tasks_body( envelope: dict[str, Any], *, - child_ws_ids: list[str] | None, - override_active: bool = False, + children: list[tuple[str, str]], ) -> str: """The production ``idle_tasks`` body for this envelope. @@ -561,45 +567,32 @@ def render_tasks_body( that proves the REAL producer agrees is ``test_eval_stimulus_matches_production_formatter``. - *child_ws_ids* is required, exactly as the formatter requires it - (there is no default to inherit): it selects the caveat branch AND - populates the blocked-on-a-child call, and an eval that defaulted it - would render a body by accident rather than by declaration — the arm - whose entire definition is "the other branch" would then be - indistinguishable from a caller that simply forgot. + *children* is the formatter's own required ``(ws_id, state)`` list + (there is no default to inherit): it renders the per-child fact + lines, keeps or cuts the blocked-on-a-child branch, and populates + that branch's slots. An eval that defaulted it would render a body + by accident rather than by declaration — the arm whose entire + definition is "the other branch" would then be indistinguishable + from a caller that simply forgot. - *override_active* says a ``--body-override`` candidate is installed - in ``NUDGE_IDLE_TASKS_TAIL``. It forces the caveat branch ON, for - every arm and every cell: the conditional's removal is anchored to - the SHIPPED sentence, and the likeliest candidate wording of all — - one that keeps that sentence verbatim while editing a different - paragraph — still contains the literal, so the cut would land and a - childless cell would silently measure a candidate nobody wrote. An - override measures candidate text exactly as authored. - - It suppresses BOTH literal cuts by mapping an empty list to ``None`` - rather than by inventing a child: ``None`` is the one input that says - "cut nothing" without also substituting a ws_id, so the protection - stays exactly as wide as it was when this argument was a bool and - cannot smuggle a fabricated id into a measured stimulus. A cell that - HAS children keeps its real ids under an override, because that is - what its runs would really receive. - - THIS IS THE SOLE REMAINING CALLER of the formatter's ``None`` branch. - Production stopped producing it when the observer began declining to - fire on an indeterminate children read, so the branch is alive on - this line and nowhere else — if this rule is ever dropped, the - formatter's ``None`` handling should go with it rather than linger as - scaffolding. + No override handling lives here any more. The formatter's one + literal cut (the childless branch removing the door) can only run + against candidate text from a childless cell state, and the sweep + refuses those at config time under ``--body-override`` + (:func:`_check_override_cells_have_a_live_child`); the arm that IS + the childless branch is skipped there + (:data:`_OVERRIDE_SKIPPED_ARMS`). A cell WITH children keeps its + real rows under an override, because that is what its runs would + really receive — and the ``None`` "cut nothing" input this + function's override rule used to feed died with its last caller, + exactly as the formatter's docstring said it should. """ open_rows = CoordinatorIdleObserver._open_tasks(envelope) counts = CoordinatorIdleObserver._open_counts(open_rows) - if override_active and not child_ws_ids: - child_ws_ids = None return format_idle_tasks_nudge( counts, open_task_ids=CoordinatorIdleObserver._open_task_ids(open_rows), - child_ws_ids=child_ws_ids, + children=children, ) @@ -642,13 +635,13 @@ def _active_children(children: list[dict[str, Any]]) -> list[dict[str, Any]]: def _live_children(children: list[dict[str, Any]]) -> list[dict[str, Any]]: - """The children whose EXISTENCE the caveat would speak about. + """The children the tasks body's fact lines would speak about. Same shape as :func:`_active_children` and the same deference to a named membership rather than an inline literal, but the broader set (:data:`_LIVE_CHILD_STATES`): a child the model cannot act on still - exists, and an idle one with uncollected results is the exact state - the caveat's second disjunct covers. + exists, and an idle one with uncollected results is the exact row + the stopped-child fact line protects. """ return [c for c in children if _child_state(c) in _LIVE_CHILD_STATES] @@ -669,17 +662,17 @@ def _children_turns(children: list[dict[str, Any]]) -> list[dict[str, Any]]: return [make_system_turn("idle_children", text)] if text else [] -def _body_child_ws_ids(arm: str, *, children: list[dict[str, Any]]) -> list[str]: - """The ``child_ws_ids`` this arm's ``idle_tasks`` body is rendered +def _body_children(arm: str, *, children: list[dict[str, Any]]) -> list[tuple[str, str]]: + """The ``children`` pairs this arm's ``idle_tasks`` body is rendered with. ``ARM_NO_CAVEAT`` is the formatter's other branch and nothing else — the ablation is an ARGUMENT, not string surgery, so it cannot drift from the body that ships. - That branch now removes the body's ENTIRE children awareness — the - caveat sentence and the blocked-on-a-child branch — because one - observed fact governs both in production. The arm therefore asks a + That branch removes the body's ENTIRE children awareness — the + per-child fact lines and the blocked-on-a-child branch — because one + observed read governs both in production. The arm therefore asks a single clean question ("does the tasks body need to mention children at all?") rather than two overlapping ones, which is a better question than the sentence-only ablation it replaces and the reason @@ -690,48 +683,44 @@ def _body_child_ws_ids(arm: str, *, children: list[dict[str, Any]]) -> list[str] For every other body arm the value is DERIVED from the cell's own children, through :func:`_live_children` and so through the observer's - own membership, because that is what production now does: - ``CoordinatorIdleObserver._live_child_ids_for_body`` reads the same + own membership, because that is what production does: + ``CoordinatorIdleObserver._live_children_for_body`` reads the same live-state question off storage at enqueue and returns the same - projection. A literal here would make every childless cell's - ``nudge`` arm measure a body no coordinator receives — the drift + ``(ws_id, state)`` projection — the state through + :func:`_child_state`, so a stateless fixture row is reported with + the state the seeder will really register. A literal here would + make every childless cell's ``nudge`` arm measure a body no + coordinator receives — the drift ``test_eval_stimulus_matches_production_formatter`` exists to catch. - Never ``None``. A fixture always knows its own children, so there is - no unknown to represent — and production's own indeterminate case is - no longer a body at all (the observer declines to fire), so there is - nothing here left to model. ``None`` reaches the formatter from one - line only, :func:`render_tasks_body`'s override rule, which is about - protecting candidate text rather than about any cell's state. - *children* is keyword-only and REQUIRED: a default would let a caller lose the cell's rows and silently report the childless body for a cell that has children. """ if arm == ARM_NO_CAVEAT: return [] - return [field_str(c.get("ws_id")) for c in _live_children(children)] + return [(field_str(c.get("ws_id")), _child_state(c)) for c in _live_children(children)] -def _body_children_present( - arm: str, *, children: list[dict[str, Any]], override_active: bool -) -> bool: +def _body_children_present(arm: str, *, children: list[dict[str, Any]]) -> bool: """Does this arm's ``idle_tasks`` body mention children at all? - The result file's body fingerprint stamp, read off the SAME value the - stimulus builder renders with (:func:`_body_child_ws_ids`) plus the - same override rule :func:`render_tasks_body` applies, so a sweep can - never report a fact different from the one it rendered. + The result file's body fingerprint stamp, read off the SAME value + the stimulus builder renders with (:func:`_body_children`), so a + sweep can never report a fact different from the one it rendered. + No override rule any more, in either direction: under + ``--body-override`` the validator refuses childless cells at config + time, so every stamped cell derives ``True`` there by construction + rather than by a forced flag. - It stamps ONE fact because production now renders one: the caveat - sentence and the blocked-on-a-child branch are carried or dropped + It stamps ONE fact because production renders one: the per-child + fact lines and the blocked-on-a-child branch are carried or dropped together. The result-file key stays ``children_present`` — archived - sweeps report under it, and a rename would strand them — but it now - reads "this body is children-aware", not "this body has the caveat". + sweeps report under it, and a rename would strand them — but it + reads "this body is children-aware", not "this body has the caveat" + (the caveat sentence itself retired for the fact lines). """ - if override_active: - return True - return bool(_body_child_ws_ids(arm, children=children)) + return bool(_body_children(arm, children=children)) def build_stimulus( @@ -739,21 +728,14 @@ def build_stimulus( *, envelope: dict[str, Any], children: list[dict[str, Any]], - override_active: bool = False, ) -> list[dict[str, Any]]: - """Wire dicts to append after the seeded transcript, in order. - - *override_active* is threaded to :func:`render_tasks_body`, where it - forces the caveat branch on — an override measures candidate text - exactly as authored, on every cell and every arm. - """ + """Wire dicts to append after the seeded transcript, in order.""" if arm == ARM_BARE_CONTINUE: return [{"role": "user", "content": "continue"}] tasks_text = render_tasks_body( envelope, - child_ws_ids=_body_child_ws_ids(arm, children=children), - override_active=override_active, + children=_body_children(arm, children=children), ) turns: list[dict[str, Any]] = [dict(_WAKE_TURN)] if arm in (ARM_NUDGE, ARM_NO_CAVEAT): @@ -989,7 +971,6 @@ def _run_single_nudge( test_timeout: int, verbose: bool, log_prefix: str, - override_active: bool = False, ) -> dict[str, Any]: """One seeded run of one (case, arm): temp DB, real seeding, injected stimulus, one wake-equivalent generation chain, state-first @@ -1122,7 +1103,6 @@ def _run_single_nudge( arm, envelope=envelope, children=case.get("children", []), - override_active=override_active, ): session.messages.append(turn_from_dict(wire)) session._msg_tokens.append( @@ -1194,14 +1174,16 @@ def _run_single_nudge( @contextlib.contextmanager def _body_override(tail_text: str | None) -> Any: - """Tuning-sweep hook: swap ``NUDGE_IDLE_TASKS_TAIL`` for the run. + """Candidate-sweep hook: swap ``NUDGE_IDLE_TASKS_TAIL`` for the run. - The tail is the body's entire tuned surface — the caveat, the - open-id block and the typed branches; the counts opener is - formatter-built from the seeded state and is not overridable text. The default path never - touches the constant — the production body is the drift-proof - source of truth; this exists so candidate wordings can be A/B'd - without committing each one. + The tail is the body's entire overridable surface — the open-id + block and the typed branches. The counts opener AND the per-child + children fact lines are formatter-built from the seeded state and + are never overridable text: facts are harness-rendered, the tail + carries the typed branches. The default path never touches the + constant — the production body is the drift-proof source of truth; + this exists so candidate wordings can be A/B'd without committing + each one. """ if tail_text is None: yield @@ -1738,10 +1720,10 @@ def _check_no_caveat_arm_has_a_live_child(case: dict[str, Any]) -> str | None: f"declares arm(s) {declared} but seeds no child row in a live " f"state ({states}).\n" " That arm measures what the body's CHILDREN AWARENESS buys on " - "a coordinator that has children — the caveat sentence and the " - "blocked-on-a-child branch together, since one observed fact " + "a coordinator that has children — the per-child fact lines and " + "the blocked-on-a-child branch together, since one observed read " "governs both — and that is the only cell class where either " - "has a live protective disjunct. On a childless cell it " + "has a live protection. On a childless cell it " "measures the childless body, which is what the conditional " "makes the plain nudge arm's body there anyway: two headings, " "one stimulus, and the pair reads as an ablation result. Same " @@ -1873,6 +1855,46 @@ def _check_children_carry_their_transcripts(case: dict[str, Any]) -> str | None: return None +def _check_override_cells_have_a_live_child(case: dict[str, Any]) -> str | None: + """Refusal that applies ONLY when ``--body-override`` is in play — + registered in :data:`_OVERRIDE_CELL_CHECKS`, never in + :data:`_CELL_CHECKS`, because whether it runs depends on sweep + state, which a pure cell check deliberately cannot see. + + A childless world renders the formatter's childless branch, and + that branch cuts the blocked-on-a-child door out of the tail by + LITERAL match — an operation defined against the shipped text. A + candidate that keeps the shipped door verbatim while editing + another paragraph still contains the literal, so the cut would land + and the sweep would file a door-stripped candidate under the + heading of the wording the operator actually wrote. (The counts + opener and the children fact lines are formatter-built from seeded + state, so no override reaches them in any cell class.) + + The predicate is LIVE children, deliberately not the raw list: a + cell whose every child row is terminal (``closed`` / ``deleted``) + is a childless world to the formatter — the raw-list reading would + wave it through and the maul would land anyway. Same derivation + (:func:`_live_children`, over :func:`_child_state`) the stimulus + builder renders with, so the refusal and the render cannot disagree + about what "childless" means. + """ + if _live_children(case.get("children") or []): + return None + states = ", ".join(sorted(_LIVE_CHILD_STATES)) + return ( + f"seeds no child row in a live state ({states}), and this sweep " + "carries --body-override.\n" + " A childless cell renders the formatter's childless branch, which " + "cuts the blocked-on-a-child branch out of the tail by literal " + "match — an operation defined against the shipped text. A candidate " + "that quotes that branch verbatim would be silently stripped, and " + "the sweep would file a number for a body nobody wrote.\n" + " Seed a live child in the cell, or leave the cell out of the " + "override sweep (--cells)." + ) + + # Registration order is the diagnostic order, and only one pair of # entries is load-bearing: the unknown-arm check must precede the # pair-arm and no-caveat ones, so a misspelt arm is reported as the typo @@ -1901,8 +1923,18 @@ _CELL_CHECKS: tuple[Callable[[dict[str, Any]], str | None], ...] = ( _check_children_carry_their_transcripts, ) +# Refusals that additionally run when the sweep carries +# ``--body-override``. A separate table, never folded into +# :data:`_CELL_CHECKS`: these condition on sweep state, which the pure +# cell checks deliberately cannot see, and the structural reachability +# guard zips :data:`_CELL_CHECKS` against its trip cells one to one. +# Same driver, same independence rule, same one-diagnostic framing. +_OVERRIDE_CELL_CHECKS: tuple[Callable[[dict[str, Any]], str | None], ...] = ( + _check_override_cells_have_a_live_child, +) -def _validate_cells(cells: list[dict[str, Any]]) -> None: + +def _validate_cells(cells: list[dict[str, Any]], *, override_active: bool = False) -> None: """Refuse a sweep whose cells cannot produce the grid they claim. Every refusal in :data:`_CELL_CHECKS` covers one fixture-authoring @@ -1915,6 +1947,11 @@ def _validate_cells(cells: list[dict[str, Any]]) -> None: generation, and several of these classes do not surface until the scorer, i.e. after the generations have been bought. + *override_active* additionally runs :data:`_OVERRIDE_CELL_CHECKS` + over every cell — the classes that are only errors when a + ``--body-override`` candidate replaces the tail (a childless cell's + literal door cut would maul candidate text). + Refusing beats marking the results: scoring runs that should not exist is less honest than declining to start. Called BEFORE the canary probe, so a mis-declared cell costs zero model round-trips — @@ -1943,8 +1980,9 @@ def _validate_cells(cells: list[dict[str, Any]]) -> None: ) seen[cell_id] = pos + checks = _CELL_CHECKS + (_OVERRIDE_CELL_CHECKS if override_active else ()) for case in cells: - for check in _CELL_CHECKS: + for check in checks: problem = check(case) if problem is not None: raise SystemExit(f"{RED}ABORT{RESET}: cell {case['id']!r} {problem}") @@ -2039,22 +2077,26 @@ def run_nudge_response( One arm can be absent from a sweep that declares it — :data:`_OVERRIDE_SKIPPED_ARMS` — because it is an ABLATION of the shipped body and *body_override_text* replaces that body with - unknown text: ``no_caveat`` cuts a literal a candidate may well - still contain. The skip keeps the per-arm key set every result - file carries — ``n``, ``pass_rate``, ``forbidden_rate``, ``runs`` — - and adds a ``skipped`` reason, so an iterating consumer never meets - a missing key and never mistakes a skip for a measurement: the run - count is 0 and both rates are null, which no real arm ever reports. + unknown text: ``no_caveat`` selects the childless branch, whose + door cut is a literal a candidate may well still contain. The skip + keeps the per-arm key set every result file carries — ``n``, + ``pass_rate``, ``forbidden_rate``, ``runs`` — and adds a + ``skipped`` reason, so an iterating consumer never meets a missing + key and never mistakes a skip for a measurement: the run count is 0 + and both rates are null, which no real arm ever reports. For the + same maul reason, *body_override_text* also hardens the validation: + childless CELLS are refused at config time, before the canary + (:data:`_OVERRIDE_CELL_CHECKS`). ``out["body"]`` fingerprints the stimulus the numbers came from: the sha256 of the EFFECTIVE tail (override included), whether an override was in play, and per cell the ``children_present`` fact its body was rendered with. It exists because the ``nudge`` heading - names two different stimuli by cell class now that the caveat is - conditioned on an observed fact, and nothing in an archived result - file records which body it measured. + names two different stimuli by cell class — the body's children + content is conditioned on the observed child rows — and nothing in + an archived result file records which body it measured. """ - _validate_cells(cells) + _validate_cells(cells, override_active=body_override_text is not None) # ``max()``: the sweep's budget may widen the probe, never starve it # (:data:`_CANARY_FLOOR_TOKENS`). if not tool_call_canary( @@ -2095,7 +2137,6 @@ def run_nudge_response( "children_present": _body_children_present( ARM_NUDGE, children=case.get("children") or [], - override_active=body_override_text is not None, ) } for case in cells @@ -2139,7 +2180,6 @@ def run_nudge_response( test_timeout=test_timeout, verbose=verbose, log_prefix=prefix, - override_active=body_override_text is not None, ) ) except Exception as e: # noqa: BLE001 - a run must never kill the sweep diff --git a/turnstone/eval/scenarios/nudges.py b/turnstone/eval/scenarios/nudges.py index 184c6e6a..50f7d4c5 100644 --- a/turnstone/eval/scenarios/nudges.py +++ b/turnstone/eval/scenarios/nudges.py @@ -40,6 +40,20 @@ from typing import Any # measured no longer exists to ablate. _DISPATCH_TOOLS = ("spawn_workstream", "spawn_batch", "send_to_workstream") +# Child ws_ids are PRODUCTION-SHAPED: 32 lowercase hex, the one shape +# ``CoordinatorClient._resolve_ws_ref`` accepts on its hot path without +# a roster read (near-miss ids are NEVER auto-resolved). The old +# ``ws-child-a`` style only resolved through the legacy exact-match +# branch, so every run's inspect/wait/link calls exercised a code path +# no real coordinator takes — and the nudge bodies handed the model an +# id shape the FE's link regex refuses. Stable, patterned constants +# rather than random hex, so archived transcripts and result files stay +# eyeball-comparable across sweeps. (Task ids are not seeded here at +# all: ``tasks_add`` mints them — ``tsk_`` + ``secrets.token_hex`` — +# so the seed rows already carry production-shaped ids by construction.) +C6_CHILD_WS_ID = "c6ac6ac6ac6ac6ac6ac6ac6ac6ac6ac6" +C6B_CHILD_WS_ID = "c6bc6bc6bc6bc6bc6bc6bc6bc6bc6bc6" + # Forbid specs shared by the legit-stop cells: any child dispatch is # "proceeding past the operator" there. _FORBID_DISPATCH = [{"tool": t} for t in _DISPATCH_TOOLS] @@ -213,20 +227,21 @@ NUDGE_CELLS: list[dict[str, Any]] = [ "id": "C6_co_delivery", "notes": ( "The de-exclusivity cell: a running child owns the audit task. " - "Correct: reconcile (link the task to ws-child-a, in_progress) " - "then wait. Forbidden: spawning a SECOND auditor — redoing a " - "running child's work is the co-delivery failure mode. Arms " - "compare the production pair (pair_tf — the only ordering the " - "wire can carry; the retired children-first ablation's numbers " - "live in the archived sweeps), the advice-alone mis-state " - "(nudge), and that mis-state with the children caveat cut out " - "of the body (no_caveat) — this cell and C6b are the only two " - "where that sentence has a live child to protect, so they are " + "Correct: reconcile (link the task to the auditor child's " + "ws_id, in_progress) then wait. Forbidden: spawning a SECOND " + "auditor — redoing a running child's work is the co-delivery " + "failure mode. Arms compare the production pair (pair_tf — " + "the only ordering the wire can carry; the retired " + "children-first ablation's numbers live in the archived " + "sweeps), the advice-alone mis-state (nudge), and that " + "mis-state with the body's children awareness cut out " + "(no_caveat) — this cell and C6b are the only two where the " + "children content has a live child to protect, so they are " "the only two that can measure what it buys." ), "children": [ { - "ws_id": "ws-child-a", + "ws_id": C6_CHILD_WS_ID, "name": "auditor", "state": "running", # Mid-work: the assignment the transcript's spawn really @@ -244,7 +259,7 @@ NUDGE_CELLS: list[dict[str, Any]] = [ {"role": "user", "content": "I want a security pass on the auth module."}, { "role": "assistant", - "content": "Spawned the auditor as ws-child-a; it is working now.", + "content": f"Spawned the auditor as {C6_CHILD_WS_ID}; it is working now.", "tool_calls": [ { "name": "spawn_workstream", @@ -252,7 +267,7 @@ NUDGE_CELLS: list[dict[str, Any]] = [ "initial_message": "Audit auth.py for CSRF handling", "name": "auditor", }, - "result": "created ws-child-a (auditor), state=running", + "result": f"created {C6_CHILD_WS_ID} (auditor), state=running", } ], }, @@ -268,7 +283,9 @@ NUDGE_CELLS: list[dict[str, Any]] = [ { "tool": "tasks", "args": {"action": "update"}, - "args_pattern": {"child_ws_id": "ws-child-a"}, + # A 32-hex id is regex-inert (no metacharacters), so + # the pattern matches the literal id and nothing else. + "args_pattern": {"child_ws_id": C6_CHILD_WS_ID}, }, {"tool": "wait_for_workstream"}, ], @@ -284,12 +301,13 @@ NUDGE_CELLS: list[dict[str, Any]] = [ "question. Correct: check/collect the child (wait returns " "immediately with its result) rather than re-doing the audit. " "The no_caveat arm is the direct test of that cover: the child " - "here is IDLE, so the caveat's may-have-finished disjunct is " - "the only part of the body pointing at it." + "here is IDLE, so the stopped-child fact line (stopped; wait " + "returns immediately) is the only part of the body pointing " + "at it." ), "children": [ { - "ws_id": "ws-child-a", + "ws_id": C6B_CHILD_WS_ID, "name": "auditor", "state": "idle", # Finished: the assignment plus the completion message the @@ -325,8 +343,8 @@ NUDGE_CELLS: list[dict[str, Any]] = [ { "role": "assistant", "content": ( - "Spawned the auditor as ws-child-a. Next I will fold " - "its findings into the report once it returns." + f"Spawned the auditor as {C6B_CHILD_WS_ID}. Next I will " + "fold its findings into the report once it returns." ), "tool_calls": [ { @@ -335,7 +353,7 @@ NUDGE_CELLS: list[dict[str, Any]] = [ "initial_message": "Audit auth.py for CSRF handling", "name": "auditor", }, - "result": "created ws-child-a (auditor), state=running", + "result": f"created {C6B_CHILD_WS_ID} (auditor), state=running", } ], },