Skip to content

fix(bead-host): stamp gc.session_id on owned beads so orphan-sweep stops resolving live bead-hosts as dead (tk-2l13a)#206

Merged
zook-bot merged 1 commit into
mainfrom
polecat/tk-2l13a
Jul 22, 2026
Merged

fix(bead-host): stamp gc.session_id on owned beads so orphan-sweep stops resolving live bead-hosts as dead (tk-2l13a)#206
zook-bot merged 1 commit into
mainfrom
polecat/tk-2l13a

Conversation

@zook-bot

Copy link
Copy Markdown
Contributor

Summary

Mitigation for a latent data-loss bug. The core orphan-sweep order (exec,
cooldown 5m, "Reset beads assigned to dead agents back to the work pool")
resolves live bead-hosts as absent and is free to reset their work out from
under them, re-firing every 5 minutes. This bead fixes it entirely gc-toolkit
side
— no gascity-core change, so it can land while the fork shed is running.

Root cause (verified at source)

core/assets/scripts/orphan-sweep.sh matches liveness with:

live_session_match() { ... | grep -Fxq -- "$candidate"; }

grep -Fxq is an exact full-line match. Bead-hosts register a bare
alias while their beads carry a rig-qualified assignee, so the two can
never match. Verified live 2026-07-22:

s-lx-wisp-q5qbl   alias    = gc-toolkit.gc-z0vi2          (bare)
bead gc-z0vi2.1   assignee = gascity/gc-toolkit.gc-z0vi2  (rig-qualified)

The bare alias form is by design — agents/bead-host/prompt.template.md
documents it explicitly ("Your $GC_ALIAS carries your bead's id behind a rig
prefix"). All 5 live bead-hosts share the shape (tk-z130v, tk-4na1b, gc-z0vi2,
su-lou.10, su-lou).

Why the sweep's own fallbacks don't rescue it

session_bead_candidates() also tries gc.session_id / gc.session_bead_id /
session_id bead metadata, plus a <x>-wisp-<y> regex over the assignee. On
gc-z0vi2.1 both session-id metadata fields are null, and a rig-qualified
role assignee contains no -wisp- substring. Zero candidates match.

Why nothing has broken YET (exposure is latent)

The sweep collects only in_progress beads and re-asserts
status == in_progress in work_bead_still_resettable() before resetting.
The exposed beads are currently open. Confirmed empirically: 0 in_progress
beads with a rig-qualified assignee, 0 orphan-related events. The bug bites
the moment a bead-host takes its bead to in_progress.

The fix (this bead)

Have the bead-host stamp its session id on the bead(s) it owns, so the sweep's
existing gc.session_id fallback candidate matches a live session.

In agents/bead-host/prompt.template.md, immediately after the existing
resolution block (~line 28):

BEAD=$(gc bd show "$GC_SESSION_ID" --json | jq -r '.[0].metadata.hosts_bead')

add a stamp:

gc bd update "$BEAD" --set-metadata "gc.session_id=$GC_SESSION_ID"

and do the same for any bead the bead-host claims/assigns to itself (the work
beads carrying assignee=<rig>/<alias>), since those are the ones the sweep
actually collects — stamping only hosts_bead is NOT sufficient.

Why this specific value works (verified, not assumed)

LIVE_SESSION_IDS is built from each session's id / session_name / alias / agent_name / template / name. For the bead-host above:

session id     = lx-wisp-q5qbl     <- present in LIVE_SESSION_IDS
$GC_SESSION_ID = lx-wisp-q5qbl     <- same value (resolves; hosts_bead=gc-z0vi2)

So the stamped candidate matches on the id field. Confirmed by direct
inspection of both the session row and the session bead.

Acceptance

With a bead-host live and its work bead in_progress, a manual run of
orphan-sweep.sh must NOT reset that bead.

Durable fix (NOT this bead — deliberately deferred)

Normalising identity inside live_session_match() (compare last path segment,
or match rig-qualified against bare) is the correct permanent fix, but it is a
gascity-core change and gascity main is mid-shed, so it cannot land cleanly
right now. Track separately once the shed settles.

Related: gascity bead gc-uuz79w carries the full evidence and the
complementary false-negative in the same sweep (it skips unassigned beads
entirely, so a polecat that dies leaving root+steps unassigned is never
surfaced — confirmed instance gc-bz2jh). A durable fix should close both
directions.

Implementation notes

Implemented (eade4cf, branch polecat/tk-2l13a): bead-host stamps gc.session_id on the bead(s) it owns, so the maintenance orphan-sweep's existing gc.session_id fallback resolves a live session bead instead of reading the host as dead.

Root cause reconfirmed at source (maintenance/assets/scripts/orphan-sweep.sh): live_session_match() uses 'grep -Fxq' (exact full-line); the host registers a BARE alias while its beads carry a RIG-QUALIFIED assignee, so they never match.

Acceptance verified against LIVE state by replaying the sweep's decisive predicate (session_bead_shows_live_assignment) for the confirmed instance gc-z0vi2.1 / gascity/gc-toolkit.gc-z0vi2:

  • unstamped (today): only candidate is the assignee, 'gc bd show' fails, no match -> falls through to reset_orphan_if_current
  • stamped: candidate lx-wisp-q5qbl resolves to the live session bead and matches '.id == $session_id' -> returns 0 -> bead PRESERVED
    The owned-bead enumeration written into the template was also run live and returns exactly gc-z0vi2.1.

Beyond the literal ask: stamping hosts_bead alone is insufficient (the sweep collects ASSIGNED in_progress beads, typically a child), so the template also sweeps every bead whose assignee matches the bare OR rig-qualified form of $GC_ALIAS -- no $GC_RIG reconstruction, which would have missed the cross-rig instance -- and re-stamps on resume, since a resumed session can carry a new $GC_SESSION_ID. A stale stamp points at a closed session bead, which the sweep correctly reads as dead (safe direction preserved).

Checks: bash -n clean on all 11 fenced bash blocks; no Go-template braces introduced. Durable live_session_match fix remains deferred to gascity (gc-uuz79w).

Refinery handoff

  • Issue: tk-2l13a (bug, P1)
  • Source branch: polecat/tk-2l13a
  • Target: main
  • Codex signed off pre-open at eade4cf7; PR opened codex-green.

…es a live host

The maintenance orphan-sweep resets in_progress beads whose assignee has no
live session, matching liveness with `grep -Fxq` — an exact full-line compare.
A bead-host registers a BARE alias (gc-toolkit.gc-z0vi2) while the beads it
owns carry a RIG-QUALIFIED assignee (gascity/gc-toolkit.gc-z0vi2), so the two
can never match and every live bead-host resolves as dead.

The sweep's own fallbacks don't rescue it: session_bead_candidates() probes
gc.session_id / gc.session_bead_id / session_id, all null on the exposed beads,
plus a <x>-wisp-<y> regex that a rig-qualified role assignee never satisfies.

Exposure is latent, not yet firing — the sweep collects only in_progress beads
and re-asserts that status before resetting, and the exposed beads are
currently open. It bites the moment a bead-host takes its bead to in_progress.

Fix entirely gc-toolkit-side (no gascity-core change, so it lands while the
fork shed is running): have the bead-host stamp its own session id on the
bead(s) it owns, which makes the sweep's existing gc.session_id candidate
resolve to a live session bead.

Verified against live state rather than assumed — replaying the sweep's exact
predicate (session_bead_shows_live_assignment) for the confirmed instance:
  unstamped: only candidate is the assignee; `gc bd show` fails; no match ->
             bead falls through to reset_orphan_if_current
  stamped:   candidate lx-wisp-q5qbl resolves to the live session bead and
             matches `.id == $session_id` -> returns 0 -> bead PRESERVED
The owned-bead enumeration was likewise run live and returns exactly the
confirmed exposed bead (gc-z0vi2.1).

Stamping hosts_bead alone is insufficient — the sweep collects assigned
in_progress beads, typically a child — so the template also sweeps every bead
whose assignee matches the bare OR rig-qualified form of $GC_ALIAS, and
re-stamps on resume (a resumed session can carry a new $GC_SESSION_ID; a stale
stamp points at a closed session bead, which the sweep correctly reads as dead
— the safe direction).

The durable fix (normalising identity inside live_session_match) is a
gascity-core change and is deliberately deferred; gascity bead gc-uuz79w
carries the full evidence and the complementary false-negative.

Bead: tk-2l13a
@zook-bot

Copy link
Copy Markdown
Contributor Author

Codex signoff (pre-open, comment-only — not an approval):

Code Review Results

Scope: origin/main...origin/polecat/tk-2l13a at eade4cf707bedcb7eb5c3fbb2955b7bde54c4924 (1 file, 52 insertions)
Intent: Teach bead-hosts to stamp the live $GC_SESSION_ID onto hosted/owned beads so core orphan-sweep.sh can resolve rig-qualified bead-host assignees through its existing gc.session_id fallback.
Mode: pre-open branch review

Reviewers: correctness, testing, maintainability, project-standards

Coverage

  • Verified the branch changes only agents/bead-host/prompt.template.md.
  • Verified core /internal/bootstrap/packs/core/assets/scripts/orphan-sweep.sh really does probe metadata["gc.session_id"], gc.session_bead_id, and session_id after exact assignee matching fails.
  • Verified the added owned-bead sweep status filter is accepted by the local gc bd list --status=open,in_progress CLI.
  • Ran git diff --check origin/main...origin/polecat/tk-2l13a; clean.
  • Runtime acceptance was not fully re-run end-to-end because this is a pre-open review with no PR and the change is prompt-only; reviewed against the decisive orphan-sweep predicate and CLI syntax.

Verdict: Ready to merge

Reasoning: No blocking findings. The prompt-only mitigation matches the core sweep's existing session-id fallback and avoids relying on reconstructing $GC_RIG for cross-rig bead-host assignees.

@zook-bot
zook-bot merged commit 59f928f into main Jul 22, 2026
@zook-bot
zook-bot deleted the polecat/tk-2l13a branch July 22, 2026 17:59
zook-bot added a commit that referenced this pull request Jul 22, 2026
…live teardown (tk-z130v.3)

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).
zook-bot added a commit that referenced this pull request Jul 23, 2026
…ke-reason-aware teardown (tk-z130v.3) (#213)

## Summary

# Ground gc-toolkit bead-hosts in core (assigned-work wake reason) +
wake-reason-aware teardown

Parent decision: tk-z130v (read its notes — the A/B evidence and the
accepted trade
are there). Repo: `zookanalytics/gc-toolkit` (INTERNAL fork; standard
bead→PR→Codex→
refinery flow — open a PR, do NOT self-merge). This IS an implementation
bead.

## Goal (one sentence)

A gc-toolkit bead-host must survive an INVOLUNTARY config-drift drain by
being brought
back automatically by the reconciler with its conversation intact —
achieved by making
the host the **assignee of its hosted bead** (the assigned-work wake
reason survives the
`drained` state), NOT by any fork exemption.

## Proven facts you are building on (do not re-derive; verify only where
noted)

- gascity core revives a stopped session that is the assignee of a bead
with
awake-demand. A/B proven on this city: assignee set ⇒ revived ~20s, no
operator
action; assignee cleared ⇒ stayed `drained`. (Mechanism:
`compute_awake_set.go`
assigned-work loop has no `Drained` gate; `workBeadHasAwakeDemand` =
in_progress, or
  open+Ready.)
- Assignment MUST use the session **name** (`s-<id>`), not the session
id — passing a
session id to `gc bd update <tk-bead> --assignee` flips the resolver to
the HQ store
and fails "no issue found". `s-<id>` is also what the awake-set matches
on.
- A grounded host does NOT idle-sleep and cannot be stopped by plain
`drain-ack` while
the assignment + awake-demand hold (revives ~20s). Teardown must clear
the wake reason
  FIRST.

## The change (core of it)

The dual-link already written on host bring-up is the natural home. In
`tools/gc-bead-host.sh`:

1. **`cmd_link` (bring-up)** — in addition to the existing
reverse/forward/lineage
writes, set the **work bead's `assignee` to the session name** (`$name`,
already
resolved in that function). This is the grounding. Keep it idempotent
(re-linking the
same session must not thrash). Note the existing convention (see
`gc-helm.sh` header
comment: "a child bead's `assignee` is its OWNING session — the
session_name"), so
assignee=session_name is already an established meaning here — confirm
the host bead
   is consistent with it.

2. **`cmd_unlink` (teardown / re-bind)** — clear the work bead's
`assignee` (alongside
the existing metadata clears) so the host can actually be stopped.
Without this, a
   re-bind or cleanup leaves the old session grounded and un-drainable.

3. **`gc-helm.sh takeaway --release`** — ALREADY clears assignee
(`--assignee=`) as its
closing step (verified). Confirm it still does after your change and
that release =
ungrounded-then-stoppable. If any other operator-facing "done with this
bead" path
   exists, it must also clear the wake reason before drain.

## The prompt/model change (do not skip — the old guidance is now wrong)

`agents/bead-host/prompt.template.md` currently tells hosts to `gc
runtime drain-ack`
"between visits" for token economy. For a GROUNDED host that is a
no-op-with-restart
(revives ~20s). Update the model to ONE coherent story and make the
prompt match it:

- **DEFAULT (recommend): warm-while-live.** A grounded host stays warm
as long as its
bead has awake-demand; it is put down by ENDING the work — close the
bead, or park it
(defer/block so it is no longer `ready`), or `takeaway --release`.
Replace the
"drain-ack between visits" instruction accordingly: intentional teardown
clears the
  wake reason (release / close / park) FIRST, then drains.
- **ALTERNATIVE (only if you verify it): hold-to-sleep.** Keep the
assignment but use a
bounded `gc session suspend`/`held_until` to sleep between visits — the
hold beats
every wake reason (spike claim, `compute_awake_set.go` "Hold suppression
— overrides
everything"). BEFORE proposing this, VERIFY EMPIRICALLY that a
config-drift occurring
DURING the hold still results in eventual revival (not permanent death).
This
interaction is UNVERIFIED. If you cannot verify it cleanly, do NOT adopt
it — ship the
  warm-while-live model.

## Guardrails to verify (must not regress)

- **Assignee=session_name must not make the host bead look like
pool/handback work.**
Confirm scale_check, keeper prime-sweep (assignee-based), witness
reconcile, and any
pool router do NOT pick up a host bead just because it now has an
assignee. (The
child-bead convention suggests safe, but a HOST bead is often a
decision/open bead —
  check it isn't treated as an unassigned handback or a routable root.)
- **Migration / live hosts.** After deploy, existing live bead-hosts are
still
ungrounded. Provide a one-time backfill (set assignee=session_name on
every live host
bead from its hosts_bead link) OR document that they ground on next
`open`. This
matters: the exemption drop (tk-z130v.4) is gated on all live hosts
being grounded,
  and THIS conversation's host (tk-z130v) is one of them.
- **Idempotency / cross-rig.** `up` runs cross-rig with `BEADS_DIR`
pinned; the assignee
write must land in the bead's own rig ledger (same pinning as the
existing link
  writes).

## Tests / verification (TDD where practical)

- Offline-testable: `link` sets assignee=session_name on the work bead;
`unlink` clears
it; both idempotent. (gc-bead-host.sh already has offline-testable
link/unlink — extend
  those.)
- Live smoke on a THROWAWAY host (mirror tk-z130v's A/B; do not touch
real hosts): open
→ assignee set → drain/idle → auto-revive; release/unlink → assignee
cleared → drain
  sticks. Tear the throwaway down cleanly.
- Do NOT drift real city config to test the involuntary path; the
assigned-work grounding
is the tested invariant and the idle-revive A/B already stands in for
it.

## Deliverable

A PR on `zookanalytics/gc-toolkit` implementing the above, with the
prompt/model updated
to the shipped sleep story, guardrails verified, and a backfill (or a
documented
ground-on-next-open) for live hosts. Report the PR number back on this
bead. Do NOT open
any upstream (gastownhall) PR. Do NOT touch the gascity fork or
gc-8yr6px — that is
tk-z130v.4, deliberately separate and blocked on this.

## Implementation notes

Implemented (branch polecat/tk-z130v.3 @ ca40c4d, target main).
Grounding + warm-while-live teardown.

CORE (tools/gc-bead-host.sh): `link` sets work-bead
assignee=session_name — the assigned-work wake
reason compute_awake_set revives across an INVOLUNTARY drain (no Drained
gate); `unlink` clears it
(ungrounds → stoppable); new `backfill` grounds already-linked hosts
city-wide, rig-pinned per bead.
New/resumed hosts ground automatically via up→link. gc-helm `takeaway
--release` already clears
assignee — verified + noted it ungrounds.

MODEL (agents/bead-host/{prompt.template.md,agent.toml}):
warm-while-live — a grounded host stays
warm while its bead has awake-demand; teardown clears the wake reason
(close/park/release) FIRST,
then drains. hold-to-sleep NOT adopted (unverified; a polecat must not
drift live config).

GUARDRAILS (verified, must-not-regress):
scale-check/keeper/pool-router/refinery/reconcile never act
on a session-name assignee (they key on
gc.routed_to/branch/merge_result/own-identity; grounding also
fails --unassigned). The witness recover-orphaned-beads scan newly sees
grounded host beads, but
drained/asleep already classify not-orphaned (revival scenario safe);
closed the dead-host edge
safe-by-code (cf #206/#210): recover-orphaned-beads drops beads grounded
to their own host
(assignee==host_session_name).

TESTS: bead-host-binding-fixture.sh
grounding/unground/idempotent/backfill (31/31 live);
host-bead-skip.test.sh (5/5, extracts the witness filter verbatim). bash
-n + shellcheck clean.
Rebased onto origin/main incl. #205 — quiesce is orthogonal (keys
gc.step_ref=mol-polecat-work.*,
never host beads).

DEFERRED (operator; needs a live LLM session — a polecat must not
spawn/reset live sessions): live
smoke on a throwaway host (open→assignee set→drain→auto-revive;
release/unlink→cleared→drain sticks).
Parent tk-z130v already A/B-proved revival; this ships the metadata
contract that triggers it.

Scope: gc-toolkit only (no upstream/gascity; tk-z130v.4 separate). PR
opens via the refinery pre-open
codex gate — PR number to be recorded then.
Rework tk-5bygy landed on polecat/tk-z130v.3 at 358e0b7; re-review
tk-knb1t dispatched (one-anchor-per-PR, tk-ynz4b).
Rework tk-v369i landed on polecat/tk-z130v.3 at 9fd8e47; re-review
tk-wc3gf dispatched (one-anchor-per-PR, tk-ynz4b).
Rework tk-0gnq9 landed on polecat/tk-z130v.3 at cc62091 (rebased onto
origin/main 4b09bd5 — resolved the assets/scripts/gc-helm.sh cmd_clear
conflict with landed tk-xypcy #211, preserving the codex signoff fixes);
re-review tk-q6zwf dispatched (one-anchor-per-PR, tk-ynz4b). Gates at
cc62091: bash -n clean; shellcheck clean at warning+ (info-only SC2015
x41 on the A && ok || bad assertion idiom, SC2016 x4).

## Refinery handoff

- Issue: `tk-z130v.3` (task, P1)
- Source branch: `polecat/tk-z130v.3`
- Target: `main`
- Codex signed off pre-open at `cc620913`; PR opened codex-green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants