Skip to content

Commit 4c31756

Browse files
steveyeggekab0rn
andauthored
Extract independent collaterals from PR #3624 (#3693)
Lands the drive-by fixes and additions from #3624 that stand on their own, separate from the speculative nostown/swarm foundation (which remains under review pending a roadmap decision). Included: - internal/beads/beads_agent.go: fix cross-rig routing in CreateAgentBead so polecat agent beads land in the target rig's DB (not the caller's. - internal/config/groq.go: GroqJSONEnforcement constant for groq-compound non-interactive prompts. - internal/doctor/groq_compound_check.go + test: gt doctor probe that verifies groq-compound returns valid JSON when configured. - internal/cmd/doctor.go: register the new check. - internal/tmux/testmain_test.go: sentinel session keeps the tmux test server alive across test runs (fixes stale-socket flakiness). - .github/workflows/nightly-integration.yml: bump bd to v0.57.0. Excluded (under review in #3624): - Swarm hook + SwarmConfig/NonInteractiveConfig types + sling_dispatch.go changes + swarm tests — depend on a nostown binary that is not yet a committed roadmap item. - Refinery manager groq-force-claude guard — silently overrides operator intent; needs separate scrutiny. - cost_tier.go groq preset change — reverts live-key resolution to a sentinel; behavioral change kept out of this split. - internal/cmd/scheduler_integration_test.go rewrites — superseded by main's direct-SQL approach to the crystallizes column issue. EOF ) Co-authored-by: Keith <37914030+kab0rn@users.noreply.github.com>
1 parent 31d53b0 commit 4c31756

7 files changed

Lines changed: 265 additions & 1 deletion

File tree

.github/workflows/nightly-integration.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333

3434
- name: Install beads (bd)
3535
if: steps.cache-beads.outputs.cache-hit != 'true'
36-
run: go install github.com/steveyegge/beads/cmd/bd@v0.55.4
36+
run: go install github.com/steveyegge/beads/cmd/bd@v0.57.0
3737

3838
- name: Install Dolt
3939
run: |

internal/beads/beads_agent.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,14 @@ func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue,
248248
return a
249249
}
250250

251+
// Create agent bead in the target database. Use a routed Beads instance
252+
// when the bead's prefix routes to a different rig than our own database.
253+
// Without this, agent beads for rig polecats (e.g., be-beads-polecat-rust)
254+
// would be created in the wrong database, failing type validation.
255+
if targetDir != b.getResolvedBeadsDir() {
256+
target = NewWithBeadsDir(filepath.Dir(targetDir), targetDir)
257+
}
258+
251259
out, err := target.run(buildArgs()...)
252260
if err != nil {
253261
out, err = target.run(buildArgs()...)

internal/cmd/doctor.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ func runDoctor(cmd *cobra.Command, args []string) error {
172172
d.Register(doctor.NewBeadsBinaryCheck())
173173
d.Register(doctor.NewDoltBinaryCheck())
174174
d.Register(doctor.NewClaudeBinaryCheck())
175+
d.Register(doctor.NewGroqCompoundCheck())
175176
d.Register(doctor.NewDoltServerReachableCheck())
176177

177178
d.Register(doctor.NewTownGitCheck())

internal/config/groq.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package config
2+
3+
// GroqJSONEnforcement is appended to prompts for groq-compound non-interactive
4+
// invocations to enforce JSON-only output from the compound model.
5+
const GroqJSONEnforcement = "\n\n---\nRESPONSE FORMAT: Respond ONLY with a single " +
6+
"valid JSON object. No text, markdown, or code fences outside the JSON. " +
7+
"If unable to comply, return: {\"error\": \"<reason>\"}"
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package doctor
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"strings"
9+
10+
"github.com/steveyegge/gastown/internal/config"
11+
)
12+
13+
14+
// GroqCompoundCheck probes the groq-compound agent for JSON output compliance.
15+
// It is skipped when groq-compound is not configured in any role.
16+
type GroqCompoundCheck struct {
17+
BaseCheck
18+
}
19+
20+
// NewGroqCompoundCheck creates a new groq-compound JSON probe check.
21+
func NewGroqCompoundCheck() *GroqCompoundCheck {
22+
return &GroqCompoundCheck{
23+
BaseCheck: BaseCheck{
24+
CheckName: "groq-compound-json",
25+
CheckDescription: "Probe groq-compound agent for JSON output compliance",
26+
CheckCategory: CategoryInfrastructure,
27+
},
28+
}
29+
}
30+
31+
// Run executes the groq-compound JSON probe check.
32+
func (c *GroqCompoundCheck) Run(ctx *CheckContext) *CheckResult {
33+
// Skip if groq-compound is not configured for any role.
34+
if !c.groqCompoundConfigured(ctx.TownRoot) {
35+
return &CheckResult{
36+
Name: c.Name(),
37+
Status: StatusOK,
38+
Message: "groq-compound not configured (skipped)",
39+
}
40+
}
41+
42+
// Skip if GROQ_API_KEY is not set.
43+
if os.Getenv("GROQ_API_KEY") == "" {
44+
return &CheckResult{
45+
Name: c.Name(),
46+
Status: StatusWarning,
47+
Message: "GROQ_API_KEY not set (skipped)",
48+
}
49+
}
50+
51+
// Skip if claude binary is not available.
52+
if _, err := exec.LookPath("claude"); err != nil {
53+
return &CheckResult{
54+
Name: c.Name(),
55+
Status: StatusWarning,
56+
Message: "claude binary not found (skipped)",
57+
}
58+
}
59+
60+
// Build probe prompt with JSON enforcement appended.
61+
// NonInteractiveConfig equivalent: {OutputFormat:"json", NoColor:true, MaxTurns:1}
62+
probe := `Respond with exactly: {"status":"ok"}` + config.GroqJSONEnforcement
63+
64+
out, err := c.invokeGroqCompound(probe)
65+
if err != nil {
66+
return &CheckResult{
67+
Name: c.Name(),
68+
Status: StatusError,
69+
Message: fmt.Sprintf("invocation failed: %v", err),
70+
}
71+
}
72+
73+
// Attempt JSON unmarshal; pass if it succeeds, fail with raw output if not.
74+
var result map[string]interface{}
75+
if err := json.Unmarshal(out, &result); err != nil {
76+
return &CheckResult{
77+
Name: c.Name(),
78+
Status: StatusError,
79+
Message: "groq-compound returned non-JSON output",
80+
Details: []string{strings.TrimSpace(string(out))},
81+
}
82+
}
83+
84+
return &CheckResult{
85+
Name: c.Name(),
86+
Status: StatusOK,
87+
Message: "groq-compound returns valid JSON",
88+
}
89+
}
90+
91+
// invokeGroqCompound calls the claude binary with Groq routing and returns stdout.
92+
func (c *GroqCompoundCheck) invokeGroqCompound(prompt string) ([]byte, error) {
93+
groqAPIKey := os.Getenv("GROQ_API_KEY")
94+
env := append(os.Environ(),
95+
"ANTHROPIC_BASE_URL=https://api.groq.com/openai/v1",
96+
"ANTHROPIC_MODEL=compound-beta",
97+
"ANTHROPIC_API_KEY="+groqAPIKey,
98+
)
99+
cmd := exec.Command("claude",
100+
"--dangerously-skip-permissions",
101+
"--output-format", "json",
102+
"--max-turns", "1",
103+
"-p", prompt,
104+
)
105+
cmd.Env = env
106+
return cmd.Output()
107+
}
108+
109+
// groqCompoundConfigured returns true if groq-compound is configured for any role
110+
// in the town or any registered rig.
111+
func (c *GroqCompoundCheck) groqCompoundConfigured(townRoot string) bool {
112+
if townRoot == "" {
113+
return false
114+
}
115+
townSettings, err := config.LoadOrCreateTownSettings(config.TownSettingsPath(townRoot))
116+
if err != nil {
117+
return false
118+
}
119+
if townSettings.DefaultAgent == string(config.AgentGroqCompound) {
120+
return true
121+
}
122+
for _, agent := range townSettings.RoleAgents {
123+
if agent == string(config.AgentGroqCompound) {
124+
return true
125+
}
126+
}
127+
return false
128+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package doctor
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/steveyegge/gastown/internal/config"
9+
)
10+
11+
func TestNewGroqCompoundCheck(t *testing.T) {
12+
c := NewGroqCompoundCheck()
13+
if c.Name() != "groq-compound-json" {
14+
t.Errorf("Name() = %q, want %q", c.Name(), "groq-compound-json")
15+
}
16+
if c.Description() == "" {
17+
t.Error("Description() is empty")
18+
}
19+
if c.Category() != CategoryInfrastructure {
20+
t.Errorf("Category() = %v, want %v", c.Category(), CategoryInfrastructure)
21+
}
22+
if c.CanFix() {
23+
t.Error("CanFix() = true, want false")
24+
}
25+
}
26+
27+
func TestGroqCompoundConfigured_EmptyTownRoot(t *testing.T) {
28+
c := NewGroqCompoundCheck()
29+
if c.groqCompoundConfigured("") {
30+
t.Error("groqCompoundConfigured(\"\") = true, want false")
31+
}
32+
}
33+
34+
func TestGroqCompoundConfigured_NoSettingsFile(t *testing.T) {
35+
tmp := t.TempDir()
36+
c := NewGroqCompoundCheck()
37+
if c.groqCompoundConfigured(tmp) {
38+
t.Error("groqCompoundConfigured with no settings file = true, want false (default agent is claude)")
39+
}
40+
}
41+
42+
func TestGroqCompoundConfigured_DefaultAgent(t *testing.T) {
43+
tmp := t.TempDir()
44+
settings := config.NewTownSettings()
45+
settings.DefaultAgent = string(config.AgentGroqCompound)
46+
writeTownSettings(t, tmp, settings)
47+
48+
c := NewGroqCompoundCheck()
49+
if !c.groqCompoundConfigured(tmp) {
50+
t.Error("groqCompoundConfigured = false, want true when DefaultAgent is groq-compound")
51+
}
52+
}
53+
54+
func TestGroqCompoundConfigured_RoleAgent(t *testing.T) {
55+
tmp := t.TempDir()
56+
settings := config.NewTownSettings()
57+
settings.RoleAgents["refinery"] = string(config.AgentGroqCompound)
58+
writeTownSettings(t, tmp, settings)
59+
60+
c := NewGroqCompoundCheck()
61+
if !c.groqCompoundConfigured(tmp) {
62+
t.Error("groqCompoundConfigured = false, want true when a role uses groq-compound")
63+
}
64+
}
65+
66+
func TestGroqCompoundConfigured_NoMatch(t *testing.T) {
67+
tmp := t.TempDir()
68+
settings := config.NewTownSettings()
69+
settings.RoleAgents["refinery"] = "claude"
70+
writeTownSettings(t, tmp, settings)
71+
72+
c := NewGroqCompoundCheck()
73+
if c.groqCompoundConfigured(tmp) {
74+
t.Error("groqCompoundConfigured = true, want false when no role uses groq-compound")
75+
}
76+
}
77+
78+
func TestGroqCompoundCheck_Run_SkipWhenNotConfigured(t *testing.T) {
79+
tmp := t.TempDir()
80+
c := NewGroqCompoundCheck()
81+
res := c.Run(&CheckContext{TownRoot: tmp})
82+
if res.Status != StatusOK {
83+
t.Errorf("Run status = %v, want OK (skip path)", res.Status)
84+
}
85+
}
86+
87+
func TestGroqCompoundCheck_Run_WarnWhenNoAPIKey(t *testing.T) {
88+
tmp := t.TempDir()
89+
settings := config.NewTownSettings()
90+
settings.DefaultAgent = string(config.AgentGroqCompound)
91+
writeTownSettings(t, tmp, settings)
92+
93+
t.Setenv("GROQ_API_KEY", "")
94+
c := NewGroqCompoundCheck()
95+
res := c.Run(&CheckContext{TownRoot: tmp})
96+
if res.Status != StatusWarning {
97+
t.Errorf("Run status = %v, want Warning when GROQ_API_KEY missing", res.Status)
98+
}
99+
}
100+
101+
func writeTownSettings(t *testing.T, townRoot string, settings *config.TownSettings) {
102+
t.Helper()
103+
dir := filepath.Join(townRoot, "settings")
104+
if err := os.MkdirAll(dir, 0755); err != nil {
105+
t.Fatalf("mkdir settings: %v", err)
106+
}
107+
if err := config.SaveTownSettings(config.TownSettingsPath(townRoot), settings); err != nil {
108+
t.Fatalf("SaveTownSettings: %v", err)
109+
}
110+
}

internal/tmux/testmain_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@ func TestMain(m *testing.M) {
1818
// user's personal server or the sentinel that indicates "no town context".
1919
SetDefaultSocket(socket)
2020

21+
// Start a sentinel session to keep the server alive for the entire test run.
22+
// Without this, tests that kill their last session inadvertently take down
23+
// the server, leaving a stale socket that prevents subsequent new-session
24+
// calls from restarting it (tmux sees the socket file but no listener).
25+
// The sentinel uses a name no individual test touches, so it outlives all
26+
// per-test sessions. TestMain kills the whole server at the end.
27+
if _, err := exec.LookPath("tmux"); err == nil {
28+
_ = exec.Command("tmux", "-u", "-L", socket, "new-session", "-d", "-s", "gt-test-sentinel").Run()
29+
}
30+
2131
code := m.Run()
2232

2333
// Kill the test tmux server and restore the original socket state.

0 commit comments

Comments
 (0)