Skip to content

Commit 1cbc445

Browse files
fix(gt-4ly): use bd set-state for agent state + read from labels
Replace the removed `bd agent state` call with `bd set-state`, which writes a dimension:value label (agent_state:<state>) and an event bead for the audit trail. Labels are indexed and queryable across rigs ("bd list --label agent_state:working"). Update all read paths (GetAgentBead, getAgentBeadInfo) to scan for the agent_state:* label instead of the dead DB column or description text. Description text remains as a fallback for legacy beads. Also fix the second call site in deacon.go (found in competing PR #3283 by EthanJStark — credit for that discovery). Why bd set-state and not description-only: - Labels are a generic beads concept (not Gas Town-specific) - Indexed: bd list --label agent_state:working queries across rigs - Survives session death; supports crash detection and dashboards - Description text is unindexed free-form — a fidelity downgrade The agent_state DB column was removed in beads v0.62.0 when the agent-as-bead subsystem was extracted as Gas Town infrastructure (commit 0bd598c, "this belongs in GT, not in beads"). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d830b04 commit 1cbc445

4 files changed

Lines changed: 59 additions & 40 deletions

File tree

internal/beads/beads_agent.go

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -415,15 +415,22 @@ func (b *Beads) ResetAgentBeadForReuse(id, reason string) error {
415415
return nil
416416
}
417417

418-
// UpdateAgentState updates the agent_state field in an agent bead's description.
418+
// UpdateAgentState updates the agent_state dimension on an agent bead.
419419
//
420-
// bd agent state was removed in beads v0.62.0 as part of observable-states
421-
// deprecation (ZFC design gt-zecmc). The DB column is no longer maintained
422-
// by gt; tmux session presence is the authoritative source for liveness.
423-
// This function updates the description text only, for human-readable bd show output.
420+
// Uses `bd set-state` (beads 0.62.0+) which writes a dimension:value label
421+
// (agent_state:<state>) and an event bead for the audit trail. Labels are
422+
// indexed and queryable ("bd list --label agent_state:working") across rigs.
423+
//
424+
// bd agent state was removed in beads v0.62.0 when the agent-as-bead subsystem
425+
// was extracted as Gas Town infrastructure (commit 0bd598c). bd set-state uses
426+
// beads' generic dimension:value label convention and is not GT-specific.
427+
// Approach inspired by PR #3283 (EthanJStark). (gt-4ly)
424428
func (b *Beads) UpdateAgentState(id string, state string) (retErr error) {
425429
defer func() { telemetry.RecordAgentStateChange(context.Background(), id, state, nil, retErr) }()
426-
if err := b.UpdateAgentDescriptionFields(id, AgentFieldUpdates{AgentState: &state}); err != nil {
430+
// Use runWithRouting so bd can resolve cross-prefix agent beads (e.g., wa-*
431+
// agent beads from hq context) via routes.jsonl instead of BEADS_DIR.
432+
_, err := b.runWithRouting("set-state", id, "agent_state="+state)
433+
if err != nil {
427434
return fmt.Errorf("updating agent state: %w", err)
428435
}
429436
return nil
@@ -609,8 +616,15 @@ func (b *Beads) GetAgentBead(id string) (*Issue, *AgentFields, error) {
609616
}
610617

611618
fields := ParseAgentFields(issue.Description)
612-
// Note: agent_state column is no longer maintained (bd agent state removed in
613-
// beads v0.62.0, ZFC design gt-zecmc). Description text is authoritative.
619+
// Read agent_state from label (authoritative, set via bd set-state).
620+
// bd set-state writes "agent_state:<value>" labels — indexed and queryable.
621+
// The agent_state DB column was removed in beads v0.62.0 (gt-4ly).
622+
for _, label := range issue.Labels {
623+
if strings.HasPrefix(label, "agent_state:") {
624+
fields.AgentState = strings.TrimPrefix(label, "agent_state:")
625+
break
626+
}
627+
}
614628
return issue, fields, nil
615629
}
616630

internal/cmd/deacon.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1171,8 +1171,8 @@ func updateAgentBeadState(townRoot, agent, state, _ string) { // reason unused b
11711171
return
11721172
}
11731173

1174-
// Use bd agent state command
1175-
cmd := exec.Command("bd", "agent", "state", beadID, state)
1174+
// Use bd set-state (beads 0.62.0+); bd agent state was removed (gt-4ly).
1175+
cmd := exec.Command("bd", "set-state", beadID, "agent_state="+state)
11761176
cmd.Dir = townRoot
11771177
_ = cmd.Run() // Best effort
11781178
}

internal/daemon/lifecycle.go

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -771,7 +771,7 @@ func (d *Daemon) closeMessage(id string) error {
771771
type AgentBeadInfo struct {
772772
ID string `json:"id"`
773773
Type string `json:"issue_type"`
774-
State string // From DB column (agent_state), fallback to description
774+
State string // From agent_state label (set via bd set-state), fallback to description
775775
HookBead string // From DB column (hook_bead)
776776
RoleType string // Parsed from description: role_type
777777
Rig string // Parsed from description: rig
@@ -840,15 +840,18 @@ func (d *Daemon) getAgentBeadInfo(agentBeadID string) (*AgentBeadInfo, error) {
840840
info.Rig = fields.Rig
841841
}
842842

843-
// Use AgentState from description text (authoritative post-v0.62.0).
844-
// bd agent state was removed in beads v0.62.0 (ZFC design gt-zecmc), so the
845-
// DB column is stuck at "spawning" permanently. Description text is updated
846-
// by UpdateAgentState via UpdateAgentDescriptionFields and is the source of
847-
// truth. Fall back to DB column only if description is empty (legacy beads).
848-
if fields != nil && fields.AgentState != "" {
843+
// Read agent_state from label (authoritative, set via bd set-state).
844+
// bd set-state writes "agent_state:<value>" labels — indexed and queryable.
845+
// The agent_state DB column was removed in beads v0.62.0 (gt-4ly).
846+
for _, label := range issue.Labels {
847+
if strings.HasPrefix(label, "agent_state:") {
848+
info.State = strings.TrimPrefix(label, "agent_state:")
849+
break
850+
}
851+
}
852+
// Fall back to description text for legacy beads without label support.
853+
if info.State == "" && fields != nil {
849854
info.State = fields.AgentState
850-
} else {
851-
info.State = issue.AgentState
852855
}
853856

854857
// Use HookBead from database column directly (not from description)

internal/daemon/polecat_health_test.go

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,18 @@ func writeFakeTestTmux(t *testing.T, dir string) {
2828
}
2929

3030
// writeFakeTestBD creates a shell script in dir named "bd" that outputs a
31-
// polecat agent bead JSON. The descState parameter controls what appears in
32-
// the description text (parsed by ParseAgentFields), while
33-
// dbState controls the agent_state database column. updatedAt controls the
34-
// bead's updated_at timestamp for time-bound testing.
35-
func writeFakeTestBD(t *testing.T, dir, descState, dbState, hookBead, updatedAt string) string {
31+
// polecat agent bead JSON. The labelState parameter controls the agent_state
32+
// label (authoritative source, set via bd set-state). The descState parameter
33+
// controls the description text fallback. updatedAt controls the bead's
34+
// updated_at timestamp for time-bound testing. (gt-4ly)
35+
func writeFakeTestBD(t *testing.T, dir, descState, labelState, hookBead, updatedAt string) string {
3636
t.Helper()
3737
desc := "agent_state: " + descState
38-
// JSON matches the structure that getAgentBeadInfo expects from bd show --json
39-
bdJSON := fmt.Sprintf(`[{"id":"gt-myr-polecat-mycat","issue_type":"agent","labels":["gt:agent"],"description":"%s","hook_bead":"%s","agent_state":"%s","updated_at":"%s"}]`,
40-
desc, hookBead, dbState, updatedAt)
38+
labels := fmt.Sprintf(`["gt:agent","agent_state:%s"]`, labelState)
39+
// JSON matches the structure that getAgentBeadInfo expects from bd show --json.
40+
// agent_state DB column omitted — removed in beads v0.62.0 (gt-4ly).
41+
bdJSON := fmt.Sprintf(`[{"id":"gt-myr-polecat-mycat","issue_type":"agent","labels":%s,"description":"%s","hook_bead":"%s","updated_at":"%s"}]`,
42+
labels, desc, hookBead, updatedAt)
4143
script := "#!/bin/sh\necho '" + bdJSON + "'\n"
4244
path := filepath.Join(dir, "bd")
4345
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
@@ -52,8 +54,8 @@ func writeFakeTestBD(t *testing.T, dir, descState, dbState, hookBead, updatedAt
5254
// beads have independent lifecycles (e.g., agent done/nuked while hook_bead open).
5355
func writeFakeBDWithHookBead(t *testing.T, dir, agentState, hookBeadID, hookBeadStatus, updatedAt string) string {
5456
t.Helper()
55-
agentJSON := fmt.Sprintf(`[{"id":"gt-myr-polecat-mycat","issue_type":"agent","labels":["gt:agent"],"description":"agent_state: %s","hook_bead":"%s","agent_state":"%s","updated_at":"%s"}]`,
56-
agentState, hookBeadID, agentState, updatedAt)
57+
agentJSON := fmt.Sprintf(`[{"id":"gt-myr-polecat-mycat","issue_type":"agent","labels":["gt:agent","agent_state:%s"],"description":"agent_state: %s","hook_bead":"%s","updated_at":"%s"}]`,
58+
agentState, agentState, hookBeadID, updatedAt)
5759
hookJSON := fmt.Sprintf(`[{"id":"%s","status":"%s"}]`, hookBeadID, hookBeadStatus)
5860
script := fmt.Sprintf("#!/bin/sh\n"+
5961
"case \"$2\" in\n"+
@@ -169,19 +171,19 @@ func TestCheckPolecatHealth_SpawningGuardExpires(t *testing.T) {
169171
}
170172
}
171173

172-
// TestCheckPolecatHealth_DescriptionOverridesDBColumn verifies that the daemon
173-
// reads agent_state from the description text (source of truth), not the DB column.
174-
// bd agent state was removed in beads v0.62.0 (ZFC design gt-zecmc), so the DB
175-
// column stays at "spawning" permanently. Description text is now authoritative.
176-
func TestCheckPolecatHealth_DescriptionOverridesDBColumn(t *testing.T) {
174+
// TestCheckPolecatHealth_LabelStateOverridesDescription verifies that the daemon
175+
// reads agent_state from the label (source of truth, set via bd set-state), not
176+
// the description text. A polecat that transitioned from "spawning" to "working"
177+
// will have a stale description but an up-to-date agent_state label. (gt-4ly)
178+
func TestCheckPolecatHealth_LabelStateOverridesDescription(t *testing.T) {
177179
if runtime.GOOS == "windows" {
178180
t.Skip("test uses Unix shell script mocks for tmux and bd")
179181
}
180182
binDir := t.TempDir()
181183
writeFakeTestTmux(t, binDir)
182184
recentTime := time.Now().UTC().Format(time.RFC3339)
183-
// Description says "working" (truth) but DB column says "spawning" (stale)
184-
bdPath := writeFakeTestBD(t, binDir, "working", "spawning", "gt-xyz", recentTime)
185+
// Description says "spawning" (stale) but label says "working" (truth)
186+
bdPath := writeFakeTestBD(t, binDir, "spawning", "working", "gt-xyz", recentTime)
185187

186188
t.Setenv("PATH", binDir+":"+os.Getenv("PATH"))
187189

@@ -196,13 +198,13 @@ func TestCheckPolecatHealth_DescriptionOverridesDBColumn(t *testing.T) {
196198
d.checkPolecatHealth("myr", "mycat")
197199

198200
got := logBuf.String()
199-
// Should NOT skip due to spawning guard — description says "working"
201+
// Should NOT skip due to spawning guard — label says "working"
200202
if strings.Contains(got, "Skipping restart") {
201-
t.Errorf("daemon should use description agent_state (working), not stale DB column (spawning), got: %q", got)
203+
t.Errorf("daemon should use label agent_state (working), not stale description (spawning), got: %q", got)
202204
}
203-
// Should detect crash since description says working + session is dead
205+
// Should detect crash since label says working + session is dead
204206
if !strings.Contains(got, "CRASH DETECTED") {
205-
t.Errorf("expected CRASH DETECTED when description state is 'working' with dead session, got: %q", got)
207+
t.Errorf("expected CRASH DETECTED when label state is 'working' with dead session, got: %q", got)
206208
}
207209
}
208210

0 commit comments

Comments
 (0)