Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions agents/bead-host/agent.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 40 additions & 22 deletions agents/bead-host/prompt.template.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <addr> "..." # 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.
7 changes: 7 additions & 0 deletions assets/scripts/gc-helm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 95 additions & 0 deletions assets/scripts/host-bead-skip.test.sh
Original file line number Diff line number Diff line change
@@ -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 <bead-array-json> -> 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-<id> 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 ]
27 changes: 27 additions & 0 deletions formulas/mol-witness-patrol.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<rig>/{{binding_prefix}}<polecat-suffix>`.

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
Expand Down
Loading