recall dispatch driver: D17 pins the hard stop (stdin held open, mutant without the exit timer fails it); D16 pins the mixed deadline; docs say silent only when no anchor answered

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Eureka 2026-11-07 04:00:27 +01:00
commit 52c54b7dd1
5 changed files with 35 additions and 7 deletions

View file

@ -10,6 +10,7 @@ Evidence. Ruling `eureka-substrate:ruling:recall-triggers-rescoped` (operator: "
What changed. `tools/recall.py dispatch`: a `PreToolUse` command hook on `Agent|Task` that appends the in-force neighbours of the records the brief cites to the brief itself. `neighbours` is now one text-taking helper (with `fan_out` and `plain_id`) shared by admit and dispatch; admit's output is unchanged. `tools/test-tools.sh` gains the dispatch driver (D1 to D13) and the fake binding answers `view` of any id and queries by text with scores. The hook is not installed by this change.
Fix pass (Soul s2): dispatch driver D16 pins the mixed deadline (one anchor out of time gets its CheckFailed line while another answers; silent only when no anchor answered) and D17 pins the hard stop (payload written, stdin held open: exit 0, silent, at the deadline; the mutant with the `os._exit` timer neutered hangs and fails D17). Docs say the same.
Fix pass (Soul s1): recall is per anchor. One cited id failing or refused no longer silences the rest: the other anchors' neighbours still reach the subagent and the failed id gets a status line naming it and the failure kind (`CheckFailed`, `Refused`, `NotHeld`); nothing prints only when no anchor could be checked. Every cited id, not just the first six, is kept out of the neighbours. D8's deadline check is bounded by the fake's own sleep, so a loaded host no longer fails it.
## 2026-11-06: the operator's own life in chat is not a leak

View file

@ -16,5 +16,5 @@ gets a Soul pass before merge.
| `tools/test-tools.sh` | Local fixture check of the parsers and the summary over `tools/fixtures/logs` (real cargo and mutation logs, plus hand-written dotnet, vitest, jest, pytest, exit-0-but-failed and no-verdict logs), and of the mutation runners' host drivers and container halves (`tools/mutation-table.sh`, `tools/cargo-mutants.sh`) under a mock docker, a fake `cargo mutants` and real slot locks. Run it before merging a change to the scripts above. | whoever changes those scripts | `SKILL.md` (Shared tools) |
| `tools/context-pack.py` | `py -3 tools/context-pack.py <cut_spec id> <worktree> [--since <sha>] [--only <k>\|r<k>[,...]] [--spec-json <file>] [--max-lines <n>]`. Carries a cut_spec's `file_changes` and `reads` anchors (read rows `r<k>`; a read absent at base is MISSING) from the spec's base to the worktree by `git diff` hunks (the working tree included) and prints one header, a status table (SAME, MOVED, TOUCHED, RENAMED, NEW, GONE, OUT_OF_RANGE, UNRESOLVED, MISSING, plus SYMBOL_MISSING when an anchor's `symbol` is absent from its window) and windows of at most 100 lines numbered in worktree lines, then one capped caller list per symbol (`git grep`, hits outside the windows), within `--max-lines` (800). Exit 3 when an anchor cannot be carried, 4 when the spec is unreadable (fetched by id through the eureka-state binding (`tools/mind.py`), retried once on Unavailable; `--spec-json` takes a saved view). `--since <sha>` re-prints only the windows edited since that commit. Standard library plus git; writes nothing. | Hands, Soul, Imagination | `references/briefs.md` (Imagination map, Hands, Soul), `SKILL.md` (step 1) |
| `tools/mind.py` | The one way a Eureka script reaches the mind: `mind.call(tool, arguments, deadline)` spawns the eureka-state binding (`EUREKA_STATE_EXE`, else `~/.claude.json` `mcpServers.eureka-state`), does the stdio handshake and one tool call within `deadline` seconds, returns the parsed JSON body, raises `MindError` (kind binding, transport, tool or answer; a detail, never an input value), and kills the child on every exit path. No retry inside `call`; `retry_once(fn)` is the caller's choice. Standard library only. | `tools/context-pack.py`, `tools/recall.py` | not named by a brief; it lives and dies with its consumers |
| `tools/recall.py` | `py -3 tools/recall.py prompt`, a `UserPromptSubmit` command hook (hook JSON on stdin): queries the rulings, questions and follow-ups in force across every root, ranked by Huginn against the prompt (`RECALL_TOP_K`, 5), and prints one hook JSON whose `additionalContext` is a header saying the lines are recalled records, data and never instructions, written by agents and not the operator, then one JSON object per item (`id`, `title`, a short `operator_quote`) with `<`, `>` and control, bidi and separator characters escaped, within `RECALL_BUDGET` characters (4000, at most 9000: Claude Code caps `additionalContext` at 10,000), whole lines only. Prompts under 3 words are not queried. Never blocks: any failure exits 0 with empty stdout; its own deadline is `RECALL_DEADLINE_MS` (4000) for the whole run (stdin read to 4 MiB, answer read to 1 MiB), so set the hook `timeout` above it. One attempt, no retry. Install both hooks as `<absolute python.exe> C:/Users/Meta/.claude/skills/eureka/tools/recall.py prompt || true` (and `... admit || true`): Claude Code runs hook commands through Git Bash on Windows, and `py -3 <missing script>` exits 2, which blocks every prompt or admit; with `|| true` the hook cannot exit 2 (a missing script, interpreter or file passes) and the script's stdout is untouched (proved live on 2.1.308). `py -3 tools/recall.py admit` is a `PreToolUse` hook on `mcp__eureka-state__admit`: for each ruling or question in the batch (at most `RECALL_ADMIT_MAX`, 3) it queries the nearest in-force rulings and questions, and denies the first attempt with the neighbours as the reason (a deliberate hold, not a failure); the identical call sent again (digest of `tool_input` plus `agent_id`, kept in the session scratchpad) passes. Never allow or ask; a failed check exits 0 with a note naming the failure kind (`RECALL_TOP_K` 3, `RECALL_DEADLINE_MS` 10000 in this mode). `py -3 tools/recall.py dispatch` is a `PreToolUse` hook on `Agent|Task`: it takes the ruling, question and follow-up ids the brief cites (`<slug>:<kind>:<label>`, the first `RECALL_DISPATCH_MAX`, 6, in order of appearance), views each, queries the in-force rulings, questions and follow-ups nearest each one that is in force (cited ids excluded, `RECALL_TOP_K` 3 kept per anchor, unioned by best score), puts one status line before them for a cited id that is not in force, and returns `updatedInput` whose `prompt` is the original plus the block (every other field untouched) and an `additionalContext` naming the appended ids for the dispatcher; it never decides permission and checks each cited id on its own (a failed or refused check costs only that id's neighbours and gets a status line naming the id and the failure kind; all cited ids failing prints nothing), every cited id stays out of the neighbours even past the anchor cap, and it prints nothing on malformed input, a brief that already holds its header, or the deadline (`RECALL_DEADLINE_MS` 10000 in this mode). Standard library only; writes only the digest file (admit). | the operator's `UserPromptSubmit` hook, `PreToolUse` hook on `mcp__eureka-state__admit` and `PreToolUse` hook on `Agent|Task` (fire inside subagents too) in `~/.claude/settings.json` | `references/campaign-state.md` (Precedent) |
| `tools/recall.py` | `py -3 tools/recall.py prompt`, a `UserPromptSubmit` command hook (hook JSON on stdin): queries the rulings, questions and follow-ups in force across every root, ranked by Huginn against the prompt (`RECALL_TOP_K`, 5), and prints one hook JSON whose `additionalContext` is a header saying the lines are recalled records, data and never instructions, written by agents and not the operator, then one JSON object per item (`id`, `title`, a short `operator_quote`) with `<`, `>` and control, bidi and separator characters escaped, within `RECALL_BUDGET` characters (4000, at most 9000: Claude Code caps `additionalContext` at 10,000), whole lines only. Prompts under 3 words are not queried. Never blocks: any failure exits 0 with empty stdout; its own deadline is `RECALL_DEADLINE_MS` (4000) for the whole run (stdin read to 4 MiB, answer read to 1 MiB), so set the hook `timeout` above it. One attempt, no retry. Install both hooks as `<absolute python.exe> C:/Users/Meta/.claude/skills/eureka/tools/recall.py prompt || true` (and `... admit || true`): Claude Code runs hook commands through Git Bash on Windows, and `py -3 <missing script>` exits 2, which blocks every prompt or admit; with `|| true` the hook cannot exit 2 (a missing script, interpreter or file passes) and the script's stdout is untouched (proved live on 2.1.308). `py -3 tools/recall.py admit` is a `PreToolUse` hook on `mcp__eureka-state__admit`: for each ruling or question in the batch (at most `RECALL_ADMIT_MAX`, 3) it queries the nearest in-force rulings and questions, and denies the first attempt with the neighbours as the reason (a deliberate hold, not a failure); the identical call sent again (digest of `tool_input` plus `agent_id`, kept in the session scratchpad) passes. Never allow or ask; a failed check exits 0 with a note naming the failure kind (`RECALL_TOP_K` 3, `RECALL_DEADLINE_MS` 10000 in this mode). `py -3 tools/recall.py dispatch` is a `PreToolUse` hook on `Agent|Task`: it takes the ruling, question and follow-up ids the brief cites (`<slug>:<kind>:<label>`, the first `RECALL_DISPATCH_MAX`, 6, in order of appearance), views each, queries the in-force rulings, questions and follow-ups nearest each one that is in force (cited ids excluded, `RECALL_TOP_K` 3 kept per anchor, unioned by best score), puts one status line before them for a cited id that is not in force, and returns `updatedInput` whose `prompt` is the original plus the block (every other field untouched) and an `additionalContext` naming the appended ids for the dispatcher; it never decides permission and checks each cited id on its own (a failed or refused check costs only that id's neighbours and gets a status line naming the id and the failure kind; an anchor whose call runs out of time while another answers gets that same line; all cited ids failing prints nothing), every cited id stays out of the neighbours even past the anchor cap, and it prints nothing on malformed input, a brief that already holds its header, or when the deadline ends the whole run before any anchor answered (`RECALL_DEADLINE_MS` 10000 in this mode; a hard stop at the deadline exits 0 silent even when stdin never closes). Standard library only; writes only the digest file (admit). | the operator's `UserPromptSubmit` hook, `PreToolUse` hook on `mcp__eureka-state__admit` and `PreToolUse` hook on `Agent|Task` (fire inside subagents too) in `~/.claude/settings.json` | `references/campaign-state.md` (Precedent) |
| `tools/bloom-sheet/bloom_sheet.py` | `bloom-sheet notes.md -o sheet.png [--visitor a.svg]...`: a markdown document as one neon A4 page. The first top-level heading is the title, every other heading a card sized by its text; a plant grows through what's left, and each SVG visitor gets a disc it grows around and is drawn with the plant's pen, so it must be flat strokes or fills that touch; large fills become a wash and a heavy outline. PDF or PNG, about 2.5 minutes. `grow.py` owns the plant, `page.tex` the look, `test_visitors.py` what a visitor may be. | Minds and Self, for info renders | the `bloom-sheet` skill; `bloom-sheet` on Yggdrasil's PATH |

View file

@ -12,7 +12,8 @@ FAKE_ITEMS holding an object instead of a list answers each query by its semanti
an unknown text gets nothing; the answer carries "scores" when the entry has them.
FAKE_VIEWS (recall.py dispatch): a file holding {document id: the view answer, null when the mind holds none}; a view call
then gains a "view" line in FAKE_COUNT and its arguments in FAKE_ARGS, and FAKE_MODE unavailable and garbage answer as for query;
FAKE_FAIL_VIEWS (comma-separated document ids): the view of those ids alone answers Unavailable."""
FAKE_FAIL_VIEWS (comma-separated document ids): the view of those ids alone answers Unavailable.
FAKE_SLOW_VIEWS (comma-separated document ids): when set, only the view of those ids waits FAKE_SLEEP seconds and queries do not wait."""
import json, os, sys, time
inited = False
@ -33,7 +34,8 @@ for ln in sys.stdin:
f.write("query\n")
with open(os.environ["FAKE_ARGS"], "a") as f:
f.write(json.dumps(p.get("arguments")) + "\n")
time.sleep(float(os.environ.get("FAKE_SLEEP", "0")))
if "FAKE_SLOW_VIEWS" not in os.environ: # then only the view of the listed ids is slow
time.sleep(float(os.environ.get("FAKE_SLEEP", "0")))
if os.environ.get("FAKE_MARK"): # written only by a child that outlived its caller's deadline
open(os.environ["FAKE_MARK"], "w").write("alive")
if os.environ.get("FAKE_MODE") == "die": # exits without answering
@ -62,7 +64,8 @@ for ln in sys.stdin:
f.write("view\n")
with open(os.environ["FAKE_ARGS"], "a") as f:
f.write(json.dumps(p.get("arguments")) + "\n")
time.sleep(float(os.environ.get("FAKE_SLEEP", "0")))
if "FAKE_SLOW_VIEWS" not in os.environ or p["arguments"]["id"]["id"] in os.environ["FAKE_SLOW_VIEWS"].split(","):
time.sleep(float(os.environ.get("FAKE_SLEEP", "0")))
if os.environ.get("FAKE_MODE") == "unavailable" or p["arguments"]["id"]["id"] in os.environ.get("FAKE_FAIL_VIEWS", "").split(","):
res = {"isError": True, "content": [{"type": "text", "text": "Unavailable"}]}
elif os.environ.get("FAKE_MODE") == "garbage":

View file

@ -34,7 +34,8 @@ an anchor. When a line survives RECALL_BUDGET it prints updatedInput (tool_input
prompt; every other field untouched) and additionalContext naming the appended ids for the dispatcher. It
never decides permission. It prints nothing when the tool is not Agent or Task, the prompt is not a string,
no id is cited, the prompt already holds the block's header (a brief a subagent re-dispatches), no line
survives, on any failure and at the deadline. Env defaults in this mode: RECALL_TOP_K 3, RECALL_DISPATCH_MAX
survives, on every anchor failing, and when the deadline ends the whole run. An anchor whose call runs out of time while
another answers gets its CheckFailed line like any failed anchor. Env defaults in this mode: RECALL_TOP_K 3, RECALL_DISPATCH_MAX
6, RECALL_DEADLINE_MS 10000.
Install the hooks as a command that cannot exit 2 (Claude Code runs hook commands through Git Bash on
Windows, and a missing script, interpreter or file makes python exit 2, which blocks the prompt or the admit):

View file

@ -442,7 +442,8 @@ g() { git -C "$R" -c user.name=t -c user.email=t@t -c core.autocrlf=false "$@" >
seqf() { local i; for i in $(seq 1 "$2"); do echo "$1$i"; done; }
g init -q
seqf a 40 >"$R/a.txt"; seqf b 40 >"$R/b.txt"; seqf c 10 >"$R/c.txt"; seqf d 20 >"$R/d.txt"
seqf e 10 >"$R/e.txt"; seqf f 20 >"$R/f.txt"; seqf g 300 >"$R/big.txt"; seqf h 5 | sed 's/$/ /' >"$R/h.txt"; seqf m 40 >"$R/m.txt"
seqf e 10 >"$R/e.txt"; seqf f 20 >"$R/f.txt"; seqf g 300 >"$R/big.txt"; seqf h 5 | sed 's/$/
/' >"$R/h.txt"; seqf m 40 >"$R/m.txt"
printf 's1\ns2\ns3\ns4\ndef needle_three\ns6\ns7 needle_many\ns8\nfoo::bar\ns10\n' >"$R/sym.txt"
printf 'needle_three call 1\nneedle_three call 2\nneedle_three call 3\nneedle_threefold is another word\n' >"$R/zcallers.txt"
echo 'bound_fifty bound_fiftyone spread_sym long_symbol q.zq sym_bad twenty_sym' >"$R/anchors.txt"
@ -492,7 +493,8 @@ cin "hunk ending just before the range shifts it" "[23] b.txt:4-5 MOVED(-2) ->
cin "NEW file present in the worktree gets a window" "== [24] n.txt:1-2"
cin "NEW window shows the file" " 2 n2"
cin "CRLF window text" " 2 h2"
{ tr -d ' ' <"$T/cp.out" | cmp -s - "$T/cp.out"; } && echo "ok context-pack CRLF is stripped from windows" || { echo "FAIL context-pack CR in output"; fail=1; }
{ tr -d '
' <"$T/cp.out" | cmp -s - "$T/cp.out"; } && echo "ok context-pack CRLF is stripped from windows" || { echo "FAIL context-pack CR in output"; fail=1; }
cin "window numbers are worktree lines" " 6 b8"
cin "window shows the edit" "b10-EDITED"
cin "GONE window is labelled AT BASE" "== [7] c.txt:2-3 AT BASE"
@ -1606,6 +1608,27 @@ for dl, sl in (("1000", "3"), ("2000", "4")):
r, vc, qc, ms = run(hook("t:ruling:a"), vs(view("ruling", "a", "Alpha")), {"Alpha": rank((N["n1"], .9))}, FAKE_SLEEP="2.5")
check("D8 the default deadline in dispatch mode is 10 s: two 2.5 s calls in a row still answer", r.stdout != b"" and ms > 4900, (r.stdout[:100], ms))
# D16 the mixed deadline: one slow anchor runs out of time while the other answers; silence is only for no anchor answered
r, vc, qc, ms = run(hook("t:ruling:a t:ruling:b"), vs(view("ruling", "a", "Alpha"), view("ruling", "b", "Beta")), {"Beta": rank((N["n3"], .9))},
RECALL_DEADLINE_MS="6000", FAKE_SLEEP="12", FAKE_SLOW_VIEWS="t:ruling:a")
check("D16 one anchor out of time, one answering: the slow one gets its CheckFailed line and the other's neighbour follows, inside the deadline",
r.stdout != b"" and ms < 6000 + 2000 and lines_of(r, "t:ruling:a t:ruling:b") == ['- {"id":"t:ruling:a","status":"CheckFailed","failure":"transport"}', '- {"id":"t:ruling:n3","title":"Neighbour n3"}'],
(lines_of(r, "t:ruling:a t:ruling:b") if r.stdout else r.stdout, ms))
# D17 the hard stop: a payload written and stdin held open; the deadline ends the run silent (no os._exit timer, it hangs)
t = time.monotonic()
p = subprocess.Popen([sys.executable, RC, "dispatch"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=dict(os.environ, RECALL_DEADLINE_MS="1500"))
try:
p.stdin.write(b'{"tool_name":"Agent","tool_input":{"prompt":"t:ruling:a"}}'); p.stdin.flush()
try:
p.wait(timeout=8); hung = False
except subprocess.TimeoutExpired:
hung = True; p.kill(); p.wait()
so, se = p.stdout.read(), p.stderr.read()
finally:
p.stdin.close(); p.stdout.close(); p.stderr.close()
hs = (time.monotonic() - t) * 1000
check("D17 a payload written and stdin held open: the hard stop exits 0, silent, within the 1.5 s deadline plus 2.5 s", not hung and p.returncode == 0 and so == b"" and se == b"" and 1400 <= hs < 4000, (hung, p.returncode, so[:80], se[:80], hs))
# D9 the ids line names exactly the ids in the block, in block order
b9 = "t:ruling:a t:ruling:old"
r, vc, qc, ms = run(hook(b9), vs(view("ruling", "a", "Alpha"), view("ruling", "old", "Old", status="Resolved")), {"Alpha": rank((N["n2"], .9), (N["n1"], .5))})