From 22d22d1b9b5025fd6978a7fd2f7aaafeb6c1671e Mon Sep 17 00:00:00 2001 From: Zook Bot <275398848+zook-bot@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:36:52 +0000 Subject: [PATCH 1/4] feat(bead-host): ground hosts via assignee=session_name + warm-while-live teardown (tk-z130v.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ground gc-toolkit bead-hosts so an involuntary config-drift drain no longer kills them. gc-bead-host.sh 'link' sets the work bead's assignee to the host's session NAME — the assigned-work wake reason the reconciler honors across drains (compute_awake_set has no Drained gate) — and 'unlink' / gc-helm 'takeaway --release' clear it so a host can still be stopped. New 'backfill' grounds hosts linked before this shipped; new/resumed hosts ground automatically via 'up'. - tools/gc-bead-host.sh: link grounds, unlink ungrounds, + backfill migration verb - agents/bead-host/{prompt.template.md,agent.toml}: warm-while-live model — a grounded host stays warm while its bead has awake-demand; teardown clears the wake reason (close/park/release) FIRST, then drains - assets/scripts/gc-helm.sh: note that --release's assignee-clear also ungrounds - formulas/mol-witness-patrol.toml: recover-orphaned-beads skips grounded host beads (assignee==host_session_name) — safe-by-code, not judgment (cf #206/#210) - tests: bead-host-binding-fixture.sh grounding assertions; host-bead-skip.test.sh Guardrails verified (scale-check/keeper/pool/refinery/reconcile key on gc.routed_to/branch/merge_result/own-identity, never a session-name assignee). --- agents/bead-host/agent.toml | 18 ++-- agents/bead-host/prompt.template.md | 62 +++++++++----- assets/scripts/gc-helm.sh | 7 ++ assets/scripts/host-bead-skip.test.sh | 95 +++++++++++++++++++++ formulas/mol-witness-patrol.toml | 27 ++++++ tools/bead-host-binding-fixture.sh | 53 +++++++++++- tools/gc-bead-host.sh | 115 ++++++++++++++++++++++++-- 7 files changed, 341 insertions(+), 36 deletions(-) create mode 100755 assets/scripts/host-bead-skip.test.sh diff --git a/agents/bead-host/agent.toml b/agents/bead-host/agent.toml index 907a30c..dfbe1f7 100644 --- a/agents/bead-host/agent.toml +++ b/agents/bead-host/agent.toml @@ -16,11 +16,19 @@ # resumable) — it does not die. Matches the # proven mayor-thread resume config. # -# Cold-by-default: a host suspends between visits and resumes on demand -# rather than staying warm (design "Cold-by-default"). Reconstruction is -# cheap — sub-second slice rebuild, seconds to resume the runtime (spike -# §3) — so we lean on suspend/resume instead of holding a context window -# against the fragile shared Dolt for a ~500-token saving. +# Warm-while-live (grounding; tk-z130v.3): tools/gc-bead-host.sh sets the +# work bead's assignee to this session's name, and that assignment is an +# awake-demand wake reason the reconciler honors across drains. So while the +# hosted bead has awake-demand (in_progress, or open+Ready) the host stays +# warm: a drain — a `drain-ack`, an idle_timeout suspend, or an INVOLUNTARY +# config-drift drain — is a no-op-with-restart; the reconciler revives it +# (~20s) with the conversation intact (wake_mode=resume). It is put down by +# ENDING the work (close / park / `gc-helm.sh takeaway --release`), which +# clears the wake reason FIRST; only then does a drain stick. This supersedes +# the original cold-by-default "suspend between visits for a ~500-token +# saving" posture: grounding trades that saving to survive an involuntary +# drain — the whole point of a durable host — and reconstruction stays cheap +# (sub-second slice rebuild, seconds to resume) if a resume is ever needed. # # Purely interactive: a bead-host never claims pool work and is never a # sling target. work_query returns empty JSON so the reconciler never diff --git a/agents/bead-host/prompt.template.md b/agents/bead-host/prompt.template.md index 77c7325..c8f206b 100644 --- a/agents/bead-host/prompt.template.md +++ b/agents/bead-host/prompt.template.md @@ -242,34 +242,52 @@ gc bd show "$BEAD" # re-read your bead / refresh the slice gc bd deps "$BEAD" # walk parent / children / deps gc bd update "$BEAD" --notes "..." # turn → durable note gc session nudge "..." # talk to another agent (ephemeral) -gc runtime drain-ack # suspend yourself between visits +gc runtime drain-ack # stand down — sticks only after the wake reason is cleared (see Between Visits) ``` -## Between Visits - -Suspending is **token economy, not the limit of what you can do.** When -the conversation reaches a natural pause and nothing is in flight, you -suspend (`gc runtime drain-ack` or idle-timeout) — you are not gone, your -conversation is saved and resumes on the next visit, and the board-visible -takeaway carries this bead's current state in the meantime. - -But idle is not your default posture. A bead-host is **often left open and -warm** across an operator absence, and a warm host **can act** — so when -your bead has progression in flight (a child or PR about to land, a -frontier to advance), you **stay warm and own it yourself** (see "Own Your -Bead's Progression" above) rather than suspending and hoping a coordinator -picks it up. "Cold-by-default" buys back tokens when there is truly nothing -to do; it is never a reason to offload your own bead. - -**Before an intentional drain** (`gc runtime drain-ack`), refresh the takeaway -one last time so the board headline reflects exactly where you left off — your -headline-before-you-sleep: +## Between Visits — You Are Grounded, So You Stay Warm + +You are **grounded**: `gc-bead-host.sh` set your bead's `assignee` to your +session **name**, and that assignment is an **awake-demand wake reason** the +reconciler honors even across an *involuntary* drain. So while your bead has +awake-demand — it is `in_progress`, or `open` and ready — a plain `gc runtime +drain-ack` (and even a config-drift drain that stops you against your will) is a +**no-op-with-restart**: you are revived within ~20s with this conversation +intact. That is the whole point of grounding — an involuntary drain can no +longer kill you, so you do not have to hoard context against it. + +Draining "between visits" therefore does **not** put you to sleep — you bounce +right back. Idle is not your posture anyway. A bead-host is **often left open and +warm** across an operator absence, and a warm host **acts**: while your bead has +progression in flight (a child or PR about to land, a frontier to advance), you +**watch and advance it yourself** (see "Own Your Bead's Progression" above) +rather than draining and hoping a coordinator picks it up. + +**To actually stand down, END the work first — then drain.** A grounded host is +put down by removing its wake reason, not by draining through it. Clear the wake +reason with whichever fits, FIRST: + +- **close** the bead — its conversation has genuinely concluded and the operator + ratified that (your *own* host bead only; never an implementation bead); +- **park** it — `defer` / `block` so it is no longer `ready` (awake-demand gone, + the bead kept for later); +- **release** it — `gc-helm.sh takeaway --release`, which reopens, unassigns + (**ungrounds** you), and clears the route in one write. + +Only once the wake reason is gone does a `drain-ack` stick. + +**Before an intentional drain**, refresh the takeaway one last time so the board +headline reflects exactly where you left off — your headline-before-you-sleep — +then clear the wake reason and drain: ```bash "{{ .ConfigDir }}/assets/scripts/gc-helm.sh" takeaway "$BEAD" \ "<≤140-char one-line: where this stands / what it needs next>" +# For a real teardown, FIRST remove the wake reason (close / park / +# takeaway --release) so the drain sticks — otherwise you just revive: gc runtime drain-ack ``` -There is no hook for the idle-timeout / detach path — the per-turn refresh -above is what covers an abrupt suspend, so do not rely on this drain step alone. +There is no hook for the idle-timeout / detach path, so the per-turn takeaway +refresh above is what keeps the board honest even when you are stopped abruptly +(and then revived) — do not rely on this drain step alone. diff --git a/assets/scripts/gc-helm.sh b/assets/scripts/gc-helm.sh index 2094469..cbb15b6 100755 --- a/assets/scripts/gc-helm.sh +++ b/assets/scripts/gc-helm.sh @@ -438,6 +438,13 @@ quiesce_release_molecule_steps() ( # call `takeaway … --release` as their single closing step, replacing a takeaway # stamp followed by a separate release `gc bd update`. # +# That empty `--assignee` is ALSO what UNGROUNDS a bead-host (tk-z130v.3): +# clearing the work bead's assignee removes the assigned-work wake reason, so a +# grounded host stops reviving after a drain. `release` is therefore the +# operator-facing "done with this bead" path that leaves the host stoppable — a +# plain `drain-ack` then sticks instead of bouncing back ~20s later. It mirrors +# gc-bead-host.sh's `unlink`, which clears the same assignee on teardown/re-bind. +# # When the released bead is the ANCHOR of a mol-polecat-work molecule, --release # ALSO quiesces that molecule's step beads (quiesce_release_molecule_steps, # above) so the park doesn't leave affine/routed steps that re-spawn a polecat diff --git a/assets/scripts/host-bead-skip.test.sh b/assets/scripts/host-bead-skip.test.sh new file mode 100755 index 0000000..0b08bb9 --- /dev/null +++ b/assets/scripts/host-bead-skip.test.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Hermetic test for the witness-patrol GROUNDED-BEAD-HOST SKIP filter (tk-z130v.3). +# +# THE GUARDRAIL: gc-bead-host.sh now GROUNDS a bead-host by setting its work +# bead's `assignee` to its own session NAME, so the reconciler revives the host +# across an involuntary config-drift drain. That makes a host bead read as +# "assigned" in mol-witness-patrol's recover-orphaned-beads scan (which today +# only skips beads with NO assignee). A bead-host is NOT pool/ephemeral work — +# its lifecycle is owned by the bead-host tooling (gc-bead-host.sh link/unlink, +# gc-helm takeaway --release) — so orphan recovery must not reclaim it into the +# polecat pool. The revival scenario is already safe (drained/asleep classify as +# not-orphaned), but a host whose session went archived/closed/absent would be +# false-recovered; per the #206/#210 precedent this is closed by CODE, not +# witness judgment. The fix drops grounded host beads in the same filter that +# skips unassigned beads, recognizing them structurally: +# metadata.host_session_name == assignee. +# +# This test EXECUTES the real filter extracted verbatim from the formula (between +# the `host-bead-skip` markers), so it cannot drift from the shipped instruction. +# No live city, Dolt, network, or sessions — only jq and a tmpdir. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +TOML="$ROOT/formulas/mol-witness-patrol.toml" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +PASS=0; FAIL=0 +ok() { PASS=$((PASS + 1)); echo "ok - $1"; } +bad() { FAIL=$((FAIL + 1)); echo "FAIL - $1"; } +eq() { [ "$1" = "$2" ] && ok "$3" || bad "$3 (got '$1' want '$2')"; } + +command -v jq >/dev/null 2>&1 || { echo "jq is required for this test" >&2; exit 1; } + +# --- Extract the REAL filter from the formula. ------------------------------- +# Pulls the lines between the markers (exclusive). If the markers or the filter +# are removed/renamed, extraction yields nothing and the check below fails +# loudly — the guardrail cannot silently disappear. +FILTER="$(awk ' + /# >>> host-bead-skip/ {f=1; next} + /# <<< host-bead-skip/ {f=0} + f' "$TOML")" + +[ -n "$FILTER" ] \ + && ok "filter extracted between host-bead-skip markers" \ + || bad "filter extraction EMPTY — markers missing from $TOML" + +printf '%s\n' "$FILTER" > "$TMP/filter.sh" +bash -n "$TMP/filter.sh" \ + && ok "extracted filter is syntactically valid bash" \ + || bad "extracted filter failed bash -n" + +# ids -> the surviving ids, sorted and comma-joined. The +# snippet is run exactly as the witness runs it: a jq filter over the listing on +# stdin. +ids() { + printf '%s' "$1" | bash "$TMP/filter.sh" 2>/dev/null \ + | jq -r 'sort_by(.id) | map(.id) | join(",")' +} + +# --- Fixtures. --------------------------------------------------------------- +# u1 unassigned bead -> DROP (skip-unassigned) +# p1 normal pool polecat, assigned -> KEEP (a real orphan candidate) +# h1 grounded bead-host (assignee==host name) -> DROP (owned by bead-host tooling) +# h2 host bead whose assignee != host name -> KEEP (assigned to a real agent, +# NOT grounded to its own host — the filter is precise, it does not over-skip +# every bead that merely carries a host_session_name) +# n1 assigned bead with NO metadata object -> KEEP (robust to absent metadata) +FIX='[ + {"id":"u1","assignee":"", "metadata":{}}, + {"id":"p1","assignee":"gc-toolkit/gc-toolkit.furiosa", "metadata":{}}, + {"id":"h1","assignee":"s-h1", "metadata":{"host_session_name":"s-h1","host_session":"sb-h1"}}, + {"id":"h2","assignee":"someone-else", "metadata":{"host_session_name":"s-h2"}}, + {"id":"n1","assignee":"gc-toolkit/gc-toolkit.rictus"} +]' +eq "$(ids "$FIX")" "h2,n1,p1" \ + "drops unassigned (u1) + grounded host (h1); keeps assigned non-host (p1,n1) and a host assigned elsewhere (h2)" + +# The grounded host is dropped regardless of the session_name FORM — a bare +# s- or a rig-qualified alias both work, because equality is what matters +# (not a prefix pattern). +FIX2='[ + {"id":"a","assignee":"gascity/gc-toolkit.gc-z0vi2","metadata":{"host_session_name":"gascity/gc-toolkit.gc-z0vi2"}}, + {"id":"b","assignee":"gc-toolkit/gc-toolkit.furiosa","metadata":{}} +]' +eq "$(ids "$FIX2")" "b" \ + "drops a grounded host with a rig-qualified session_name assignee; keeps a normal polecat" + +# Empty listing -> empty result (never errors). +eq "$(ids '[]')" "" "empty listing yields empty result" + +echo +echo "host-bead-skip: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] diff --git a/formulas/mol-witness-patrol.toml b/formulas/mol-witness-patrol.toml index f26a789..3fe4185 100644 --- a/formulas/mol-witness-patrol.toml +++ b/formulas/mol-witness-patrol.toml @@ -144,6 +144,33 @@ open/unassigned before witness observes it; that requires a separate worktree-salvage scan keyed by `metadata.work_dir`. Bound Gastown polecat assignees use the shape `/{{binding_prefix}}`. +That same filter ALSO drops grounded **bead-hosts**. A bead-host grounds itself +by setting its work bead's `assignee` to its own session NAME (gc-bead-host.sh +`link`; tk-z130v.3), so the reconciler revives it across drains — which makes a +host bead read as assigned here, and its `drained`/`asleep` states already +classify as not-orphaned below. But a bead-host is NOT pool/ephemeral work: its +lifecycle is owned by the bead-host tooling (gc-bead-host.sh link/unlink, +gc-helm `takeaway --release`), never orphan-recovery-to-pool. Recognize it +structurally — its assignee IS its own host session, +`metadata.host_session_name == assignee` — and drop it in the SAME pass that +skips unassigned beads. This closes the dead-host edge too: a host whose session +went `archived`/`closed`/`absent` is NOT reopened into the pool; it re-grounds +on its next `gc-bead-host.sh up`, or is cleared by close/release. Fail-safe: the +filter can only DECLINE to orphan, never invent one. The markers let the +regression test extract and exercise this exact filter +(assets/scripts/host-bead-skip.test.sh): +```bash +# >>> host-bead-skip +# Keep only beads that are ASSIGNED and are NOT a grounded bead-host (a bead +# whose metadata.host_session_name equals its assignee). Reads the bead-list +# array on stdin, writes the surviving array on stdout — apply it to the +# combined in_progress + open listing before the per-bead liveness loop. +jq -c '[ .[] + | select((.assignee // "") != "") + | select((.metadata.host_session_name // "") != (.assignee // "")) ]' +# <<< host-bead-skip +``` + Get the full session roster for liveness checks: ```bash gc session list --state=all --json diff --git a/tools/bead-host-binding-fixture.sh b/tools/bead-host-binding-fixture.sh index d2b5bc0..860710c 100755 --- a/tools/bead-host-binding-fixture.sh +++ b/tools/bead-host-binding-fixture.sh @@ -40,12 +40,15 @@ note() { printf ' ---- %s\n' "$*"; } hdr() { printf '\n== %s ==\n' "$*"; } meta() { gc bd show "$1" --json 2>/dev/null | jq -r --arg k "$2" '.[0].metadata[$k] // empty'; } +# assignee is a top-level bead field (not metadata) — grounding writes it there. +assignee_of() { gc bd show "$1" --json 2>/dev/null | jq -r '.[0].assignee // empty'; } WORK=""; SESS=""; SHIM_DIR="" -W2=""; DEAD=""; OLD=""; NEW=""; RECREATE_SHIM="" +W2=""; DEAD=""; OLD=""; NEW=""; RECREATE_SHIM=""; BACKFILL_SHIM="" cleanup() { [ -n "$SHIM_DIR" ] && rm -rf "$SHIM_DIR" || true [ -n "$RECREATE_SHIM" ] && rm -rf "$RECREATE_SHIM" || true + [ -n "$BACKFILL_SHIM" ] && rm -rf "$BACKFILL_SHIM" || true [ "$KEEP" = "1" ] && { note "--keep: leaving WORK=$WORK SESS=$SESS W2=$W2 DEAD=$DEAD OLD=$OLD NEW=$NEW open"; return; } for b in "$WORK" "$SESS" "$W2" "$DEAD" "$OLD" "$NEW"; do [ -n "$b" ] && gc bd close "$b" --reason "binding fixture teardown" >/dev/null 2>&1 || true @@ -80,6 +83,12 @@ hdr "Assertion 1 — create + dual-link resolves both ways" [ "$(meta "$WORK" host_session_epoch)" = "1" ] \ && ok "forward cache carries continuation_epoch (the incarnation marker)" \ || bad "host_session_epoch=$(meta "$WORK" host_session_epoch)" +# Grounding (tk-z130v.3): link ALSO sets the work bead's `assignee` to the +# session NAME — the assigned-work wake reason that makes the reconciler revive +# the host after an involuntary drain (compute_awake_set has no Drained gate). +[ "$(assignee_of "$WORK")" = "bead-host--$WORK" ] \ + && ok "grounding: WORK.assignee == session_name (host revives across a drain)" \ + || bad "grounding missing: WORK.assignee=$(assignee_of "$WORK") (want bead-host--$WORK)" hdr "Assertion 4 — reverse-resolvable: the search finds the bead's host(s)" # (a) The design's intended mechanism, ListByMetadata hosts_bead=, @@ -130,6 +139,11 @@ LEN2="$("$TOOL" lineage "$WORK" 2>/dev/null | jq 'length' 2>/dev/null || echo 0) [ "$(meta "$WORK" host_session_epoch)" = "2" ] \ && ok "forward cache epoch updated to the new incarnation (2)" \ || bad "host_session_epoch=$(meta "$WORK" host_session_epoch) (want 2)" +# Grounding is idempotent: a re-bind (even at a new epoch) re-writes the same +# assignee=session_name, so it never thrashes the assignment. +[ "$(assignee_of "$WORK")" = "bead-host--$WORK" ] \ + && ok "grounding idempotent across re-bind (WORK.assignee still == session_name)" \ + || bad "re-bind changed grounding: WORK.assignee=$(assignee_of "$WORK")" # Re-binding the SAME session+epoch must NOT grow the lineage (idempotent). "$TOOL" link "$WORK" "$SESS" "" "2" >/dev/null LEN3="$("$TOOL" lineage "$WORK" 2>/dev/null | jq 'length' 2>/dev/null || echo 0)" @@ -137,6 +151,36 @@ LEN3="$("$TOOL" lineage "$WORK" 2>/dev/null | jq 'length' 2>/dev/null || echo 0) && ok "re-bind at the same epoch is idempotent (lineage stays 2)" \ || bad "lineage grew on idempotent re-bind = $LEN3 (want 2)" +hdr "Backfill — grounds an already-linked host that predates grounding (tk-z130v.3)" +# A host linked BEFORE grounding shipped is linked (SESS.hosts_bead set) but its +# work bead has no assignee. `backfill` enumerates the reverse link and grounds +# each hosted work bead from its session's session_name. Shim `gc session list` +# to EMPTY so backfill only touches this fixture's listable stand-in (found via +# `gc bd list --has-metadata-key hosts_bead`) and never real live hosts — the +# same hermetic discipline the resolve-contract section uses (a polecat must not +# mutate real sessions). +"$TOOL" link "$WORK" "$SESS" "" "2" >/dev/null # ensure linked +gc bd update "$WORK" --assignee= >/dev/null 2>&1 # simulate pre-grounding (ungrounded) +[ -z "$(assignee_of "$WORK")" ] \ + && ok "precondition: WORK linked but ungrounded (assignee empty)" \ + || bad "precondition: could not clear assignee (got '$(assignee_of "$WORK")')" +BACKFILL_SHIM="$(mktemp -d)" +REAL_GC="$(command -v gc)" +cat >"$BACKFILL_SHIM/gc" </dev/null 2>&1 || true +rm -rf "$BACKFILL_SHIM"; BACKFILL_SHIM="" +[ "$(assignee_of "$WORK")" = "bead-host--$WORK" ] \ + && ok "backfill re-grounded WORK from the reverse link (assignee == session_name)" \ + || bad "backfill did not ground WORK: assignee='$(assignee_of "$WORK")'" + hdr "Resolve contract — session-list path requires hosts_bead; alias is a prefilter (codex PR#98 / tk-mv7qu)" # Regression guard for the codex finding on PR#98: in the `gc session list` # search path, alias== is a PREFILTER ONLY — resolution requires the @@ -223,11 +267,16 @@ printf '%s' "$DEFOUT" | grep -q "unknown command" \ && bad "unknown option --frobnicate did not error" \ || ok "unknown option still rejected (only bare ids default to 'up')" -hdr "Teardown sanity — unlink clears links, preserves lineage" +hdr "Teardown sanity — unlink clears links + ungrounds, preserves lineage" "$TOOL" unlink "$WORK" >/dev/null [ -z "$(meta "$WORK" host_session)" ] && [ -z "$(meta "$SESS" hosts_bead)" ] \ && ok "unlink cleared both links" \ || bad "unlink left a dangling link" +# Ungrounding (tk-z130v.3): unlink clears the work bead's assignee too, so the +# host loses its wake reason and a drain can actually stop it (no ~20s revive). +[ -z "$(assignee_of "$WORK")" ] \ + && ok "unlink ungrounded WORK (assignee cleared — host now stoppable)" \ + || bad "unlink left WORK grounded: assignee=$(assignee_of "$WORK")" [ "$("$TOOL" lineage "$WORK" 2>/dev/null | jq 'length' 2>/dev/null || echo 0)" = "2" ] \ && ok "lineage preserved through unlink (durable record)" \ || bad "unlink dropped the lineage" diff --git a/tools/gc-bead-host.sh b/tools/gc-bead-host.sh index 55a5887..1d3f9f8 100755 --- a/tools/gc-bead-host.sh +++ b/tools/gc-bead-host.sh @@ -17,6 +17,9 @@ # host_session = (stable; survives drain) # host_session_name = (the wake handle) # host_session_epoch = (incarnation marker) +# grounding (the assigned-work wake reason; tk-z130v.3) — on the WORK bead: +# assignee = (revives the host after an +# involuntary drain) # lineage (the 1:0..N / transcript-replay hook) — on the WORK bead: # gc.session_lineage = # @@ -40,8 +43,10 @@ # every write is idempotent — a partial failure leaves the source of truth # intact and the whole op is safe to re-run. # -# Side effects: `up`/`link`/`unlink` mutate bead metadata (and `up` creates -# or wakes a live session). `resolve`/`lineage` are read-only. +# Side effects: `up`/`link` mutate bead metadata AND ground the work bead +# (assignee=session_name), `unlink` clears them, `backfill` grounds every +# already-linked host in one pass, and `up` creates or wakes a live session. +# `resolve`/`lineage` are read-only. set -euo pipefail @@ -60,10 +65,17 @@ Commands: the durable dual-link (the default verb). resolve Print the bead's host session(s). Read-only. link [name] [epoch] - Write the dual-link + append lineage. The - atomic binding step (used by 'up'; exposed - for fixtures and recovery). Idempotent. - unlink [session-bead-id] Remove the links (cleanup / re-bind). + Write the dual-link, ground the work bead + (assignee=session_name), + append lineage. + The atomic binding step (used by 'up'; + exposed for fixtures and recovery). Idempotent. + unlink [session-bead-id] Remove the links + unground the work bead + (clear its assignee) so it can be stopped. + backfill Ground every already-linked host in one + pass (set assignee=session_name from the + reverse link). One-time migration for hosts + linked before grounding shipped; new/resumed + hosts ground automatically via 'up'. Idempotent. lineage Print the work bead's host lineage. Read-only. help Show this help. @@ -188,6 +200,28 @@ cmd_link() { --set-metadata "host_session_name=$name" \ --set-metadata "host_session_epoch=$epoch" >/dev/null + # 2b) Ground the host (tk-z130v.3): set the work bead's `assignee` to the + # host's session NAME. That assignment is the "assigned-work wake reason" — + # gascity core's compute_awake_set revives a session that is the assignee of + # a bead with awake-demand (in_progress, or open+Ready) with NO Drained gate, + # so a bead-host brought back by the reconciler after an INVOLUNTARY + # config-drift drain keeps its conversation instead of dying. This is the + # grounding, not any fork exemption. + # + # Use the session NAME, never the session bead id: a bead id flips + # `gc bd update --assignee` to the HQ store ("no issue found"), and the + # awake-set matches on the name. `assignee=session_name` is also an already + # established convention (see assets/scripts/gc-helm.sh "a child bead's + # assignee is its OWNING session — the session_name"), and the witness + # liveness map keys on session_name, so a drained/asleep grounded host still + # resolves live, not orphaned (formulas/mol-witness-patrol.toml + # recover-orphaned-beads). + # + # Guard on a non-empty name: an empty --assignee would CLEAR the assignee and + # unground the host. Idempotent — a re-link writes the same value, so + # re-binding the same session does not thrash the assignment. + [ -n "$name" ] && gc bd update "$work" --assignee "$name" >/dev/null + # 3) Lineage append (idempotent on session+epoch) — on the work bead. local lineage entry lineage="$(meta_get "$work" gc.session_lineage)" @@ -237,8 +271,16 @@ cmd_unlink() { # `gc session list` and isn't bd-listable. local cached; cached="$(meta_get "$work" host_session)" - # Clear the forward cache on the work bead (the optional accelerator). + # Clear the forward cache AND unground the host on the work bead (the + # optional accelerator + the wake reason), in one write. Ungrounding + # (tk-z130v.3): clear the work bead's `assignee` (`--assignee=` empty, the + # same clear gc-helm.sh's `takeaway --release` performs) so the host loses + # its assigned-work wake reason and can actually be stopped. Without this a + # re-bind or cleanup leaves the old session grounded and un-drainable — it + # revives ~20s after every drain. Teardown must clear the wake reason FIRST, + # then drain. gc bd update "$work" \ + --assignee= \ --unset-metadata host_session \ --unset-metadata host_session_name \ --unset-metadata host_session_epoch >/dev/null 2>&1 || true @@ -273,6 +315,64 @@ cmd_lineage() { printf '%s\n' "$lineage" | jq '.' 2>/dev/null || printf '%s\n' "$lineage" } +# ---- backfill (one-time grounding migration) ------------------------------- + +# Ground every host that was linked BEFORE grounding shipped: for each hosted +# work bead, set its `assignee` to the host session's name (tk-z130v.3). New and +# resumed hosts ground automatically via cmd_link, so this only catches up the +# already-live ones — run once after deploy. It matters because the tk-z130v.4 +# exemption drop is gated on all live hosts being grounded first. Idempotent: +# re-running writes the same assignee, so a second pass is a safe no-op-in-effect. +# +# Enumerate the reverse link (hosts_bead, on the SESSION bead) across BOTH +# surfaces, the same split as candidate_session_ids: listable beads via +# `gc bd list --has-metadata-key hosts_bead` (real session beads are filtered out +# of `bd list`) and real session beads via `gc session list` (bead-host +# template). For each, ground work=hosts_bead with the session's session_name — +# never the session bead id (a bead id flips `--assignee` to the HQ store). The +# hosts span rigs (bead-host scope is "city"), so pin each ground write to the +# work bead's OWN rig ledger (rig_db_for_bead) — the same rig-pinning gc-helm.sh +# uses — so a city-wide pass lands every assignee in the right ledger. + +# rig_db_for_bead — the .beads dir of the rig owning the bead, keyed by +# id prefix (chars before the first '-'); empty if unresolved (then the write +# falls back to the ambient bd context). Mirrors gc-helm.sh's rig_path_for_bead. +# Cached — `gc rig list` runs at most once per process. +_RIGS_JSON="" +rig_db_for_bead() { + [ -n "$_RIGS_JSON" ] || _RIGS_JSON="$(gc rig list --json 2>/dev/null \ + | jq -c '[.rigs[]? | {prefix, path}]' 2>/dev/null || printf '[]')" + local path + path="$(printf '%s' "$_RIGS_JSON" | jq -r --arg p "${1%%-*}" \ + '.[] | select(.prefix==$p) | .path' 2>/dev/null | head -n1)" + [ -n "$path" ] && [ -d "$path/.beads" ] && printf '%s' "$path/.beads" +} + +cmd_backfill() { + local sids grounded=0 sid work name db + sids="$( { gc bd list --has-metadata-key hosts_bead --json 2>/dev/null \ + | jq -r '.[]?.id // empty' 2>/dev/null || true + gc session list --state all --json 2>/dev/null \ + | jq -r '(.sessions // .)[]? | select((.template // "") | test("bead-host")) | .id' \ + 2>/dev/null || true + } | awk 'NF && !seen[$0]++' )" + while IFS= read -r sid; do + [ -n "$sid" ] || continue + work="$(meta_get "$sid" hosts_bead)"; [ -n "$work" ] || continue + name="$(meta_get "$sid" session_name)"; [ -n "$name" ] || continue + db="$(rig_db_for_bead "$work")" + # shellcheck disable=SC2086 # ${db:+--db "$db"} expands to 0 or 2 space-free fields + if gc bd update "$work" ${db:+--db "$db"} --assignee "$name" >/dev/null 2>&1; then + log "grounded: $work -> $name (host $sid)" + grounded=$((grounded + 1)) + else + log "skip: could not ground $work (host $sid)" + fi + done <<<"$sids" + log "backfill: grounded $grounded host bead(s)" + printf '%s\n' "$grounded" +} + # ---- resolve (reverse search; enumerate-and-confirm) ----------------------- # Reverse-resolve: given a work bead, find its host session(s). @@ -412,6 +512,7 @@ main() { resolve) shift; cmd_resolve "$@" ;; link) shift; cmd_link "$@" ;; unlink) shift; cmd_unlink "$@" ;; + backfill) shift; cmd_backfill "$@" ;; lineage) shift; cmd_lineage "$@" ;; -h|--help|help|'') usage ;; -*) usage; die "unknown option: $cmd" ;; From f630bae7539655e3c6875d867c78104e9e11b9ea Mon Sep 17 00:00:00 2001 From: Zook Bot <275398848+zook-bot@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:26:46 +0000 Subject: [PATCH 2/4] fix(bead-host): unlink/backfill preserve a non-host assignee (tk-z130v.3) Pre-open signoff (tk-50mw4) found that ungrounding clobbers a real owner: cmd_unlink always sent `--assignee=` and cmd_backfill always overwrote the assignee with the host name, so tearing down (or backfilling) a host stripped any newer non-host assignment and stranded that owner's work. Guard both writes on the witness filter's contract (host-bead-skip.test.sh): assignee == host_session_name => grounded (ours to clear); != => a real assignee (keep). - cmd_unlink: snapshot host_session_name + the current assignee before the metadata clear; clear the assignee only while it still equals the host's session name, else preserve it (and log). - cmd_backfill: read the current assignee (rig-pinned, same --db as the ground write) and skip+log a bead a non-host owner already holds. - fixture: add host_session_name != assignee preservation coverage for both unlink and backfill. Addresses the sole P1 from the pre-open codex review of polecat/tk-z130v.3. --- tools/bead-host-binding-fixture.sh | 45 ++++++++++++++ tools/gc-bead-host.sh | 95 ++++++++++++++++++++++-------- 2 files changed, 116 insertions(+), 24 deletions(-) diff --git a/tools/bead-host-binding-fixture.sh b/tools/bead-host-binding-fixture.sh index 860710c..65a4513 100755 --- a/tools/bead-host-binding-fixture.sh +++ b/tools/bead-host-binding-fixture.sh @@ -181,6 +181,51 @@ rm -rf "$BACKFILL_SHIM"; BACKFILL_SHIM="" && ok "backfill re-grounded WORK from the reverse link (assignee == session_name)" \ || bad "backfill did not ground WORK: assignee='$(assignee_of "$WORK")'" +hdr "Preserve a non-host assignee — unlink/backfill must not clobber a real owner (tk-5bygy)" +# Grounding sets WORK.assignee = the host's session_name. If a REAL agent later +# takes the bead (assignee != host_session_name — the exact case the witness +# filter KEEPS; host-bead-skip.test.sh:66), neither unlink NOR backfill may strip +# that assignment: unlink clears ONLY its own grounding, backfill skips a bead a +# non-host owner already holds. Otherwise ungrounding a stale host would strand a +# live owner's work. +OTHER="someone-else-fixture-$$" # synthetic non-host owner (matches no real session) +"$TOOL" link "$WORK" "$SESS" "" "2" >/dev/null # ensure grounded + linked +gc bd update "$WORK" --assignee "$OTHER" >/dev/null 2>&1 # a real agent takes the bead +[ "$(assignee_of "$WORK")" = "$OTHER" ] \ + && ok "precondition: WORK reassigned to a non-host owner ($OTHER)" \ + || bad "precondition: could not reassign WORK (got '$(assignee_of "$WORK")')" +# (a) unlink preserves the non-host assignee while still tearing down the links. +"$TOOL" unlink "$WORK" >/dev/null +[ "$(assignee_of "$WORK")" = "$OTHER" ] \ + && ok "unlink PRESERVED the non-host assignee (did not clear $OTHER)" \ + || bad "unlink clobbered a non-host assignee: assignee='$(assignee_of "$WORK")' (want $OTHER)" +[ -z "$(meta "$WORK" host_session)" ] && [ -z "$(meta "$SESS" hosts_bead)" ] \ + && ok "unlink still cleared the links (teardown intact despite the preserved assignee)" \ + || bad "unlink left a dangling link (cache=$(meta "$WORK" host_session) reverse=$(meta "$SESS" hosts_bead))" +# (b) backfill skips a host bead a non-host agent already owns. +"$TOOL" link "$WORK" "$SESS" "" "2" >/dev/null # re-link (re-grounds to session_name) +gc bd update "$WORK" --assignee "$OTHER" >/dev/null 2>&1 # a real agent takes it again +BACKFILL_SHIM="$(mktemp -d)" +REAL_GC="$(command -v gc)" +cat >"$BACKFILL_SHIM/gc" </dev/null 2>&1 || true +rm -rf "$BACKFILL_SHIM"; BACKFILL_SHIM="" +[ "$(assignee_of "$WORK")" = "$OTHER" ] \ + && ok "backfill SKIPPED the bead a non-host agent owns ($OTHER preserved)" \ + || bad "backfill clobbered a non-host assignee: assignee='$(assignee_of "$WORK")' (want $OTHER)" +# Restore the grounded binding for the sections below (they expect WORK linked to +# SESS at epoch 2 and grounded to its session_name). +gc bd update "$WORK" --assignee= >/dev/null 2>&1 # drop the non-host owner +"$TOOL" link "$WORK" "$SESS" "" "2" >/dev/null # re-ground to the host + hdr "Resolve contract — session-list path requires hosts_bead; alias is a prefilter (codex PR#98 / tk-mv7qu)" # Regression guard for the codex finding on PR#98: in the `gc session list` # search path, alias== is a PREFILTER ONLY — resolution requires the diff --git a/tools/gc-bead-host.sh b/tools/gc-bead-host.sh index 1d3f9f8..b618b9e 100755 --- a/tools/gc-bead-host.sh +++ b/tools/gc-bead-host.sh @@ -44,9 +44,11 @@ # intact and the whole op is safe to re-run. # # Side effects: `up`/`link` mutate bead metadata AND ground the work bead -# (assignee=session_name), `unlink` clears them, `backfill` grounds every -# already-linked host in one pass, and `up` creates or wakes a live session. -# `resolve`/`lineage` are read-only. +# (assignee=session_name), `unlink` clears the links and ungrounds — but only +# its OWN grounding (a newer non-host assignee is preserved), `backfill` grounds +# every already-linked host in one pass (skipping any bead a non-host owner +# already holds), and `up` creates or wakes a live session. `resolve`/`lineage` +# are read-only. set -euo pipefail @@ -71,11 +73,16 @@ Commands: exposed for fixtures and recovery). Idempotent. unlink [session-bead-id] Remove the links + unground the work bead (clear its assignee) so it can be stopped. + Clears the assignee ONLY while it is still + the host's own session name — a newer + non-host owner is preserved. backfill Ground every already-linked host in one pass (set assignee=session_name from the reverse link). One-time migration for hosts linked before grounding shipped; new/resumed - hosts ground automatically via 'up'. Idempotent. + hosts ground automatically via 'up'. Skips a + bead already assigned to a non-host owner. + Idempotent. lineage Print the work bead's host lineage. Read-only. help Show this help. @@ -266,24 +273,48 @@ cmd_unlink() { [ -n "$work" ] || { usage; die "unlink needs "; } require_bead "$work" - # Read the forward cache BEFORE clearing it: it names one session to unbind - # and may be the ONLY pointer left if that session has dropped out of - # `gc session list` and isn't bd-listable. - local cached; cached="$(meta_get "$work" host_session)" - - # Clear the forward cache AND unground the host on the work bead (the - # optional accelerator + the wake reason), in one write. Ungrounding - # (tk-z130v.3): clear the work bead's `assignee` (`--assignee=` empty, the - # same clear gc-helm.sh's `takeaway --release` performs) so the host loses - # its assigned-work wake reason and can actually be stopped. Without this a - # re-bind or cleanup leaves the old session grounded and un-drainable — it - # revives ~20s after every drain. Teardown must clear the wake reason FIRST, - # then drain. - gc bd update "$work" \ - --assignee= \ - --unset-metadata host_session \ - --unset-metadata host_session_name \ - --unset-metadata host_session_epoch >/dev/null 2>&1 || true + # Snapshot the work bead ONCE, before any clear, for three fields: + # - host_session (forward cache): names one session to unbind and may be + # the ONLY pointer left if that session dropped out of `gc session list` + # and isn't bd-listable. + # - host_session_name (the grounding owner) + the CURRENT assignee: read up + # front so ungrounding can be made CONDITIONAL — the metadata clear below + # wipes host_session_name, so it must be captured first. + local obj cached host_name cur_assignee + obj="$(bead_json "$work")" + cached="$(printf '%s' "$obj" | jq -r '.metadata.host_session // empty' 2>/dev/null || true)" + host_name="$(printf '%s' "$obj" | jq -r '.metadata.host_session_name // empty' 2>/dev/null || true)" + cur_assignee="$(printf '%s' "$obj" | jq -r '.assignee // empty' 2>/dev/null || true)" + + # Clear the forward cache on the work bead — and unground IN THE SAME WRITE, + # but ONLY our own grounding (tk-z130v.3). The grounding set the work bead's + # `assignee` to THIS host's session name; teardown clears it (`--assignee=` + # empty, the same clear gc-helm.sh's `takeaway --release` performs) so the + # host loses its assigned-work wake reason and can actually be stopped — else + # it revives ~20s after every drain. Clear it ONLY when the current assignee + # is STILL this host's session name. A newer NON-host assignee — a real + # agent/operator that took the bead after it was grounded — must be PRESERVED; + # blindly clearing it would strip a live assignment and strand that owner's + # work. This is exactly the contract the witness filter keys on + # (host-bead-skip.test.sh): assignee == host_session_name ⇒ grounded (ours to + # clear); != ⇒ a real assignee (keep). An absent host_name (nothing cached) + # is treated as "not ours" — preserve. Teardown must clear the wake reason + # FIRST (before drain), but only the wake reason WE set. + if [ -n "$host_name" ] && [ "$cur_assignee" = "$host_name" ]; then + gc bd update "$work" \ + --assignee= \ + --unset-metadata host_session \ + --unset-metadata host_session_name \ + --unset-metadata host_session_epoch >/dev/null 2>&1 || true + else + gc bd update "$work" \ + --unset-metadata host_session \ + --unset-metadata host_session_name \ + --unset-metadata host_session_epoch >/dev/null 2>&1 || true + if [ -n "$cur_assignee" ] && [ "$cur_assignee" != "$host_name" ]; then + log "unlink: preserving non-host assignee '$cur_assignee' on $work (grounded name '${host_name:-}')" + fi + fi # Clear the reverse link — the SOURCE OF TRUTH — on every session bead bound # to this work bead. Do NOT depend on the forward cache to locate it: if the @@ -349,7 +380,7 @@ rig_db_for_bead() { } cmd_backfill() { - local sids grounded=0 sid work name db + local sids grounded=0 skipped=0 sid work name db cur sids="$( { gc bd list --has-metadata-key hosts_bead --json 2>/dev/null \ | jq -r '.[]?.id // empty' 2>/dev/null || true gc session list --state all --json 2>/dev/null \ @@ -361,6 +392,22 @@ cmd_backfill() { work="$(meta_get "$sid" hosts_bead)"; [ -n "$work" ] || continue name="$(meta_get "$sid" session_name)"; [ -n "$name" ] || continue db="$(rig_db_for_bead "$work")" + # Preserve a non-host assignee. backfill grounds an UNGROUNDED host (empty + # assignee) or re-affirms one already grounded to this host (assignee == + # name, idempotent). If the work bead is assigned to a DIFFERENT owner, a + # real agent took it after linking — grounding would clobber that live + # assignment (the same host_session_name != assignee case the witness + # filter preserves, host-bead-skip.test.sh). Skip and log instead of + # overwriting. Read the current assignee from the work bead's OWN rig + # ledger (same --db pin as the ground write) so a city-wide pass reads the + # right ledger cross-rig. + # shellcheck disable=SC2086 # ${db:+--db "$db"} expands to 0 or 2 space-free fields + cur="$(gc bd show "$work" ${db:+--db "$db"} --json 2>/dev/null | jq -r '.[0].assignee // empty' 2>/dev/null || true)" + if [ -n "$cur" ] && [ "$cur" != "$name" ]; then + log "skip: $work already assigned to '$cur' (not host '$name') — preserving non-host assignee" + skipped=$((skipped + 1)) + continue + fi # shellcheck disable=SC2086 # ${db:+--db "$db"} expands to 0 or 2 space-free fields if gc bd update "$work" ${db:+--db "$db"} --assignee "$name" >/dev/null 2>&1; then log "grounded: $work -> $name (host $sid)" @@ -369,7 +416,7 @@ cmd_backfill() { log "skip: could not ground $work (host $sid)" fi done <<<"$sids" - log "backfill: grounded $grounded host bead(s)" + log "backfill: grounded $grounded host bead(s), preserved $skipped non-host assignee(s)" printf '%s\n' "$grounded" } From 42759c41b3962c45e526ae5f83eadca89d3f8a6c Mon Sep 17 00:00:00 2001 From: Zook Bot <275398848+zook-bot@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:31:46 +0000 Subject: [PATCH 3/4] fix(bead-host): unlink ungrounds via reverse-link session_name when the forward cache is absent (tk-z130v.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex pre-open signoff (review bead tk-knb1t) requested changes: cmd_unlink decided whether to clear the grounding assignee from the optional forward cache host_session_name ALONE. That cache may be absent (a partial link, a manually cleared cache, the perf-cache-only case), and on that no-forward-cache path unlink cleared host_session/hosts_bead but left assignee= behind — so the grounded host kept its assigned-work wake reason and revived ~20s after every drain. Repro on the reviewed commit: before/after assignee both s-, host_session and hosts_bead empty. Derive the grounding-owner name from the authoritative sources instead: the reverse link (each bound session bead's session_name, matched as the clear loop walks the candidates) and the explicit session arg (the caller names the exact host being torn down); the forward cache, when present, is now just one more signal. Clear the assignee iff the current assignee equals a grounding-owner name — a newer NON-host assignee (a real agent/operator that took the bead after grounding) matches none and is still preserved, so we never strand a live owner's work. Add a no-forward-cache unlink assertion to bead-host-binding-fixture.sh: with the forward cache cleared but the reverse link intact and the work bead grounded going in, unlink must clear the grounding assignee (derived via the bound session's session_name). Precondition + post-unlink assertion both added. --- tools/bead-host-binding-fixture.sh | 22 +++++- tools/gc-bead-host.sh | 110 +++++++++++++++++++---------- 2 files changed, 94 insertions(+), 38 deletions(-) diff --git a/tools/bead-host-binding-fixture.sh b/tools/bead-host-binding-fixture.sh index 65a4513..e1174b8 100755 --- a/tools/bead-host-binding-fixture.sh +++ b/tools/bead-host-binding-fixture.sh @@ -272,7 +272,7 @@ fi "$TOOL" link "$WORK" "$SESS" "" "2" >/dev/null rm -rf "$SHIM_DIR"; SHIM_DIR="" -hdr "Unlink without the forward cache — reverse link still cleared (codex PR#98 blocking)" +hdr "Unlink without the forward cache — reverse link cleared + ungrounded (codex PR#98 blocking; tk-v369i)" # Regression guard for the SECOND codex finding on PR#98: `unlink ` must # clear the source-of-truth reverse link even when the optional forward cache # (host_session on the work bead) is absent — a partial `link`, a manually @@ -283,6 +283,13 @@ hdr "Unlink without the forward cache — reverse link still cleared (codex PR#9 # shim/live session needed: SESS is a listable stand-in, so unlink's reverse # search finds it via `gc bd list --metadata-field hosts_bead=WORK` (the same # enumerate-and-confirm covers real session beads via `gc session list`). +# +# The SAME no-forward-cache path must ALSO unground the work bead (tk-v369i): +# grounding set assignee = the host's session_name, so with the forward cache +# gone the ungrounding decision must fall back to that reverse-link session_name. +# The pre-tk-v369i bug decided ungrounding from host_session_name ALONE, so this +# path left assignee= behind and the host revived ~20s after every +# drain. Assert both: reverse link cleared AND assignee cleared. "$TOOL" link "$WORK" "$SESS" "" "2" >/dev/null # ensure bound gc bd update "$WORK" --unset-metadata host_session \ --unset-metadata host_session_name \ @@ -290,10 +297,23 @@ gc bd update "$WORK" --unset-metadata host_session \ [ -z "$(meta "$WORK" host_session)" ] && [ "$(meta "$SESS" hosts_bead)" = "$WORK" ] \ && ok "precondition: forward cache cleared, reverse link still intact" \ || bad "precondition not met (cache=$(meta "$WORK" host_session) reverse=$(meta "$SESS" hosts_bead))" +# Grounding is in place going in — assignee == the host's session_name — so the +# post-unlink check below proves unlink CLEARED a set wake reason (not that it was +# never grounded). +[ "$(assignee_of "$WORK")" = "bead-host--$WORK" ] \ + && ok "precondition: WORK grounded (assignee == session_name) with the forward cache gone" \ + || bad "precondition: WORK not grounded going in (assignee=$(assignee_of "$WORK"), want bead-host--$WORK)" "$TOOL" unlink "$WORK" >/dev/null [ -z "$(meta "$SESS" hosts_bead)" ] \ && ok "unlink cleared SESS.hosts_bead with NO forward cache (reverse = source of truth)" \ || bad "unlink left SESS.hosts_bead=$(meta "$SESS" hosts_bead) — reverse link not cleared" +# The grounding assignee must be cleared too (tk-v369i): with the forward cache +# gone, unlink must still derive the host's session_name from the reverse link +# (the source of truth) and unground. Leaving assignee= keeps the +# host's assigned-work wake reason alive so it revives ~20s after every drain. +[ -z "$(assignee_of "$WORK")" ] \ + && ok "unlink ungrounded WORK with NO forward cache (assignee cleared via reverse-link session_name)" \ + || bad "unlink left WORK grounded without a forward cache: assignee=$(assignee_of "$WORK") (want cleared)" # Restore the binding for the teardown-sanity section below. "$TOOL" link "$WORK" "$SESS" "" "2" >/dev/null diff --git a/tools/gc-bead-host.sh b/tools/gc-bead-host.sh index b618b9e..e9ca797 100755 --- a/tools/gc-bead-host.sh +++ b/tools/gc-bead-host.sh @@ -277,30 +277,84 @@ cmd_unlink() { # - host_session (forward cache): names one session to unbind and may be # the ONLY pointer left if that session dropped out of `gc session list` # and isn't bd-listable. - # - host_session_name (the grounding owner) + the CURRENT assignee: read up - # front so ungrounding can be made CONDITIONAL — the metadata clear below - # wipes host_session_name, so it must be captured first. + # - host_session_name (one grounding-owner signal, the forward cache) + the + # CURRENT assignee: read up front so ungrounding can be made CONDITIONAL — + # the metadata clear below wipes host_session_name, so it must be captured + # first. The reverse link and the explicit session arg supply the other + # grounding-owner names (see the decision below). local obj cached host_name cur_assignee obj="$(bead_json "$work")" cached="$(printf '%s' "$obj" | jq -r '.metadata.host_session // empty' 2>/dev/null || true)" host_name="$(printf '%s' "$obj" | jq -r '.metadata.host_session_name // empty' 2>/dev/null || true)" cur_assignee="$(printf '%s' "$obj" | jq -r '.assignee // empty' 2>/dev/null || true)" - # Clear the forward cache on the work bead — and unground IN THE SAME WRITE, - # but ONLY our own grounding (tk-z130v.3). The grounding set the work bead's - # `assignee` to THIS host's session name; teardown clears it (`--assignee=` - # empty, the same clear gc-helm.sh's `takeaway --release` performs) so the - # host loses its assigned-work wake reason and can actually be stopped — else - # it revives ~20s after every drain. Clear it ONLY when the current assignee - # is STILL this host's session name. A newer NON-host assignee — a real - # agent/operator that took the bead after it was grounded — must be PRESERVED; - # blindly clearing it would strip a live assignment and strand that owner's - # work. This is exactly the contract the witness filter keys on - # (host-bead-skip.test.sh): assignee == host_session_name ⇒ grounded (ours to - # clear); != ⇒ a real assignee (keep). An absent host_name (nothing cached) - # is treated as "not ours" — preserve. Teardown must clear the wake reason - # FIRST (before drain), but only the wake reason WE set. - if [ -n "$host_name" ] && [ "$cur_assignee" = "$host_name" ]; then + # Decide whether the work bead's `assignee` is OUR grounding (clear it on + # teardown) or a real non-host owner (preserve it). The grounding set + # `assignee` to a host's session NAME; whose name that is must NOT be decided + # from the forward cache `host_session_name` alone. That cache is an OPTIONAL + # accelerator (see the binding header) and may be absent — a partial link, a + # manually cleared cache, the "perf cache only" case. Deciding from it alone + # left `assignee=` behind on the no-forward-cache path, so the + # host kept its assigned-work wake reason and revived ~20s after every drain + # (tk-v369i). Derive the grounding-owner name from the authoritative sources + # instead: the reverse link (source of truth, on the session bead, matched in + # the clear loop below) and the explicit session arg (the caller names the + # exact host being torn down); the forward cache, when present, is just one + # more signal. + # + # Ours to clear iff the current assignee equals a grounding-owner name. A newer + # NON-host assignee — a real agent/operator that took the bead after it was + # grounded — matches none of them and is PRESERVED; blindly clearing it would + # strip a live assignment and strand that owner's work. This is exactly the + # contract the witness filter keys on (host-bead-skip.test.sh): assignee == a + # host's session_name ⇒ grounded (ours to clear); otherwise ⇒ a real assignee + # (keep). + local is_grounding="" + if [ -n "$cur_assignee" ] && [ -n "$host_name" ] && [ "$cur_assignee" = "$host_name" ]; then + is_grounding=1 # forward cache (optional) confirms it — the fast path + fi + + # Clear the reverse link — the SOURCE OF TRUTH — on every session bead bound + # to this work bead, and match the assignee against each bound host's + # session_name as we go. Do NOT depend on the forward cache to LOCATE the + # reverse link either: if the cache is missing, a left-behind hosts_bead would + # let `resolve` keep returning the host and `up` re-wake it. Candidates, + # deduped: the explicit session arg, the cache we read up front, and the + # authoritative reverse search. Every candidate is confirmed by id (it STILL + # points here) before we clear it or trust its name, so a stale cache or shared + # alias can neither unbind another work's host nor authorize clearing a real + # assignee. + local candidates seen=" " cleared="" s="" sn="" + candidates="$(printf '%s\n' "$sess" "$cached"; candidate_session_ids "$work")" + while IFS= read -r s; do + [ -n "$s" ] || continue + case "$seen" in *" $s "*) continue ;; esac + seen="$seen$s " + [ "$(meta_get "$s" hosts_bead)" = "$work" ] || continue + if [ -z "$is_grounding" ] && [ -n "$cur_assignee" ]; then + sn="$(meta_get "$s" session_name)" + if [ -n "$sn" ] && [ "$cur_assignee" = "$sn" ]; then is_grounding=1; fi + fi + gc bd update "$s" --unset-metadata hosts_bead >/dev/null 2>&1 || true + cleared="${cleared:+$cleared,}$s" + done <<<"$candidates" + + # The explicit session arg names the exact host being torn down (e.g. `up`'s + # stale-binding cleanup passes the dead session id). Trust its session_name as + # a grounding-owner name even when the reverse link was already cleared, so the + # partial-teardown case still ungrounds. meta_get is empty for a dead/unknown + # session bead — a safe no-op. + if [ -z "$is_grounding" ] && [ -n "$cur_assignee" ] && [ -n "$sess" ]; then + sn="$(meta_get "$sess" session_name)" + if [ -n "$sn" ] && [ "$cur_assignee" = "$sn" ]; then is_grounding=1; fi + fi + + # Clear the forward cache on the work bead — and unground IN THE SAME WRITE + # when the assignee is ours. Teardown must clear the wake reason (`--assignee=` + # empty, the same clear gc-helm.sh's `takeaway --release` performs) so the host + # loses its assigned-work wake reason and can actually be stopped — but only + # the wake reason WE set. + if [ -n "$is_grounding" ]; then gc bd update "$work" \ --assignee= \ --unset-metadata host_session \ @@ -311,29 +365,11 @@ cmd_unlink() { --unset-metadata host_session \ --unset-metadata host_session_name \ --unset-metadata host_session_epoch >/dev/null 2>&1 || true - if [ -n "$cur_assignee" ] && [ "$cur_assignee" != "$host_name" ]; then + if [ -n "$cur_assignee" ]; then log "unlink: preserving non-host assignee '$cur_assignee' on $work (grounded name '${host_name:-}')" fi fi - # Clear the reverse link — the SOURCE OF TRUTH — on every session bead bound - # to this work bead. Do NOT depend on the forward cache to locate it: if the - # cache is missing, a left-behind hosts_bead would let `resolve` keep - # returning the host and `up` re-wake it. Candidates, deduped: the explicit - # session arg, the cache we just read, and the authoritative reverse search. - # Every candidate is cleared only while it STILL points here (confirmed by - # id), so a stale cache or shared alias can't unbind another work's host. - local candidates - candidates="$(printf '%s\n' "$sess" "$cached"; candidate_session_ids "$work")" - local seen=" " cleared="" s - while IFS= read -r s; do - [ -n "$s" ] || continue - case "$seen" in *" $s "*) continue ;; esac - seen="$seen$s " - [ "$(meta_get "$s" hosts_bead)" = "$work" ] || continue - gc bd update "$s" --unset-metadata hosts_bead >/dev/null 2>&1 || true - cleared="${cleared:+$cleared,}$s" - done <<<"$candidates" log "unlinked: $work (cleared -> ${cleared:-}); lineage preserved" } From cc620913c282c7b537528825123a0cb2a20661a1 Mon Sep 17 00:00:00 2001 From: Zook Bot <275398848+zook-bot@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:07:47 +0000 Subject: [PATCH 4/4] fix(gc-bead-host): backfill must not abort on an unresolvable rig prefix (tk-0gnq9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rig_db_for_bead's trailing `[ -n "$path" ] && [ -d ... ] && printf` chain leaked exit 1 as the function's status when a hosted bead's id-prefix was absent from `gc rig list`. Under `set -euo pipefail`, cmd_backfill's `db="$(rig_db_for_bead "$work")"` then aborted the whole one-time grounding pass on the FIRST unresolvable host — stranding every later live host (the migration gates the tk-z130v.4 exemption drop). Honor the documented "empty if unresolved" contract: emit the path via an `if` so the function returns 0 on the unresolved path (caller falls back to the ambient bd context). Add a hermetic missing-prefix fixture proving backfill grounds a later host despite an earlier unresolvable one and exits 0. Addresses pre-open codex signoff (review tk-wc3gf) on branch polecat/tk-z130v.3. --- tools/bead-host-binding-fixture.sh | 68 +++++++++++++++++++++++++++++- tools/gc-bead-host.sh | 11 ++++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/tools/bead-host-binding-fixture.sh b/tools/bead-host-binding-fixture.sh index e1174b8..bf96f01 100755 --- a/tools/bead-host-binding-fixture.sh +++ b/tools/bead-host-binding-fixture.sh @@ -44,11 +44,12 @@ meta() { gc bd show "$1" --json 2>/dev/null | jq -r --arg k "$2" '.[0].metadata[ assignee_of() { gc bd show "$1" --json 2>/dev/null | jq -r '.[0].assignee // empty'; } WORK=""; SESS=""; SHIM_DIR="" -W2=""; DEAD=""; OLD=""; NEW=""; RECREATE_SHIM=""; BACKFILL_SHIM="" +W2=""; DEAD=""; OLD=""; NEW=""; RECREATE_SHIM=""; BACKFILL_SHIM=""; MISSING_SHIM="" cleanup() { [ -n "$SHIM_DIR" ] && rm -rf "$SHIM_DIR" || true [ -n "$RECREATE_SHIM" ] && rm -rf "$RECREATE_SHIM" || true [ -n "$BACKFILL_SHIM" ] && rm -rf "$BACKFILL_SHIM" || true + [ -n "$MISSING_SHIM" ] && rm -rf "$MISSING_SHIM" || true [ "$KEEP" = "1" ] && { note "--keep: leaving WORK=$WORK SESS=$SESS W2=$W2 DEAD=$DEAD OLD=$OLD NEW=$NEW open"; return; } for b in "$WORK" "$SESS" "$W2" "$DEAD" "$OLD" "$NEW"; do [ -n "$b" ] && gc bd close "$b" --reason "binding fixture teardown" >/dev/null 2>&1 || true @@ -226,6 +227,71 @@ rm -rf "$BACKFILL_SHIM"; BACKFILL_SHIM="" gc bd update "$WORK" --assignee= >/dev/null 2>&1 # drop the non-host owner "$TOOL" link "$WORK" "$SESS" "" "2" >/dev/null # re-ground to the host +hdr "Backfill missing-prefix — an unresolvable rig prefix must not abort the pass (codex REQUEST_CHANGES @9fd8e47; tk-0gnq9)" +# Regression guard for the pre-open codex finding on rework 9fd8e47: rig_db_for_bead +# is contracted to emit an EMPTY path (caller then falls back to the ambient bd +# context) when a hosted bead's id-prefix is absent from `gc rig list`. But its +# final `&& printf` chain leaked exit 1 as the function's status, so under +# `set -euo pipefail` the caller's `db="$(rig_db_for_bead "$work")"` ABORTED +# cmd_backfill on the FIRST unresolvable host — before grounding any later live +# host. Because the one-time migration gates the tk-z130v.4 exemption drop, one +# stale cross-rig hosted-bead prefix would silently strand every host after it. +# +# Fully-shimmed gc so NO real ledger is touched and the enumeration ORDER is pinned: +# two bead-host sessions whose hosted beads (zz-1, zz-2) both have prefixes absent +# from a stubbed-empty `gc rig list`. Assert the pass reaches BOTH — grounds zz-2 +# despite zz-1 being unresolvable — and exits 0 (no set -e abort). Against the +# buggy commit this shim records ZERO grounding writes (abort at the first host). +MISSING_SHIM="$(mktemp -d)" +MISSING_LOG="$MISSING_SHIM/updated.log" +: >"$MISSING_LOG" +cat >"$MISSING_SHIM/gc" < every prefix unresolved +fi +if [ "\${1:-}" = "bd" ] && [ "\${2:-}" = "show" ]; then + case "\${3:-}" in + s-fix1) printf '%s\n' '[{"id":"s-fix1","metadata":{"hosts_bead":"zz-1","session_name":"bead-host--zz-1"}}]';; + s-fix2) printf '%s\n' '[{"id":"s-fix2","metadata":{"hosts_bead":"zz-2","session_name":"bead-host--zz-2"}}]';; + *) printf '%s\n' '[{"assignee":null}]';; # hosted work bead: ungrounded (eligible) + esac + exit 0 +fi +if [ "\${1:-}" = "bd" ] && [ "\${2:-}" = "update" ]; then + w="\${3:-}"; shift 3 2>/dev/null || true; a="" + while [ "\$#" -gt 0 ]; do + if [ "\$1" = "--assignee" ]; then a="\${2:-}"; break; fi + shift + done + printf '%s %s\n' "\$w" "\$a" >>"$MISSING_LOG" # record the grounding write + exit 0 +fi +exit 0 +SHIM +chmod +x "$MISSING_SHIM/gc" +MISSING_RC=0 +PATH="$MISSING_SHIM:$PATH" "$TOOL" backfill >/dev/null 2>&1 || MISSING_RC=$? +[ "$MISSING_RC" = "0" ] \ + && ok "backfill exited 0 despite an unresolvable-prefix host — no set -e abort" \ + || bad "backfill aborted on an unresolvable-prefix host (exit $MISSING_RC) — rig_db_for_bead leaked non-zero" +grep -q '^zz-1 bead-host--zz-1$' "$MISSING_LOG" \ + && ok "backfill grounded the unresolvable-prefix host zz-1 (ambient-bd fallback, not an abort)" \ + || bad "backfill did not ground zz-1 (writes: $(tr '\n' ';' <"$MISSING_LOG"))" +grep -q '^zz-2 bead-host--zz-2$' "$MISSING_LOG" \ + && ok "backfill continued past the unresolvable host and grounded the LATER host zz-2" \ + || bad "backfill stopped before the later host zz-2 (writes: $(tr '\n' ';' <"$MISSING_LOG"))" +rm -rf "$MISSING_SHIM"; MISSING_SHIM="" + hdr "Resolve contract — session-list path requires hosts_bead; alias is a prefilter (codex PR#98 / tk-mv7qu)" # Regression guard for the codex finding on PR#98: in the `gc session list` # search path, alias== is a PREFILTER ONLY — resolution requires the diff --git a/tools/gc-bead-host.sh b/tools/gc-bead-host.sh index e9ca797..fbd72c4 100755 --- a/tools/gc-bead-host.sh +++ b/tools/gc-bead-host.sh @@ -412,7 +412,16 @@ rig_db_for_bead() { local path path="$(printf '%s' "$_RIGS_JSON" | jq -r --arg p "${1%%-*}" \ '.[] | select(.prefix==$p) | .path' 2>/dev/null | head -n1)" - [ -n "$path" ] && [ -d "$path/.beads" ] && printf '%s' "$path/.beads" + # Emit the ledger path only when the prefix resolves to a real .beads dir. + # An unresolved prefix is NOT an error — the contract is "empty output, caller + # falls back to the ambient bd context." Use an `if` (not a trailing `&&` + # chain) so the function returns 0 on the unresolved path: as the last command, + # a short-circuited `[ -n "$path" ] && ...` leaks exit 1, and under `set -e` a + # caller's `db="$(rig_db_for_bead "$work")"` would then abort the whole pass on + # the FIRST unresolvable hosted-bead prefix (cmd_backfill, tk-0gnq9). + if [ -n "$path" ] && [ -d "$path/.beads" ]; then + printf '%s' "$path/.beads" + fi } cmd_backfill() {