fix(bead-host): stamp gc.session_id on owned beads so orphan-sweep stops resolving live bead-hosts as dead (tk-2l13a)#206
Merged
Conversation
…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
Contributor
Author
|
Codex signoff (pre-open, comment-only — not an approval): Code Review ResultsScope: Reviewers: correctness, testing, maintainability, project-standards Coverage
|
johnzook
approved these changes
Jul 22, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Mitigation for a latent data-loss bug. The core
orphan-sweeporder (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.shmatches liveness with:grep -Fxqis an exact full-line match. Bead-hosts register a barealias while their beads carry a rig-qualified assignee, so the two can
never match. Verified live 2026-07-22:
The bare alias form is by design —
agents/bead-host/prompt.template.mddocuments it explicitly ("Your
$GC_ALIAScarries your bead's id behind a rigprefix"). 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 triesgc.session_id/gc.session_bead_id/session_idbead metadata, plus a<x>-wisp-<y>regex over the assignee. Ongc-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_progressbeads and re-assertsstatus == in_progressinwork_bead_still_resettable()before resetting.The exposed beads are currently
open. Confirmed empirically: 0 in_progressbeads 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_idfallback candidate matches a live session.In
agents/bead-host/prompt.template.md, immediately after the existingresolution block (~line 28):
add a stamp:
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 sweepactually collects — stamping only
hosts_beadis NOT sufficient.Why this specific value works (verified, not assumed)
LIVE_SESSION_IDSis built from each session'sid / session_name / alias / agent_name / template / name. For the bead-host above:So the stamped candidate matches on the
idfield. Confirmed by directinspection of both the session row and the session bead.
Acceptance
With a bead-host live and its work bead
in_progress, a manual run oforphan-sweep.shmust 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:
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
tk-2l13a(bug, P1)polecat/tk-2l13amaineade4cf7; PR opened codex-green.