1 Commits

Author SHA1 Message Date
chrome
612c59629f fix(plugin): don't record false success for manual plugin runs
Some checks failed
CI / Check for .beads changes (pull_request) Successful in 9s
CI / Check embedded formulas (pull_request) Successful in 32s
CI / Test (pull_request) Failing after 1m47s
CI / Lint (pull_request) Failing after 22s
CI / Integration Tests (pull_request) Successful in 1m35s
CI / Coverage Report (pull_request) Has been skipped
Windows CI / Windows Build and Unit Tests (pull_request) Has been cancelled
`gt plugin run` was recording ResultSuccess even though it only prints
instructions without executing them. This poisoned the cooldown gate,
blocking actual executions for 24h.

Changes:
- Record manual runs as ResultSkipped instead of ResultSuccess
- Add CountSuccessfulRunsSince() to only count successful runs for gate
- Gate check now uses CountSuccessfulRunsSince() so skipped/failed runs
  don't block future executions

Fixes: hq-2dis4c

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 01:20:12 -08:00
10 changed files with 39 additions and 368 deletions

View File

@@ -182,22 +182,6 @@ Examples:
RunE: runDogDispatch,
}
var dogDoneCmd = &cobra.Command{
Use: "done [name]",
Short: "Mark a dog as idle (work complete)",
Long: `Mark a dog as idle after completing its work.
Dogs call this command after finishing plugin execution to reset their state
to idle, allowing them to receive new work dispatches.
If no name is provided, attempts to detect the current dog from BD_ACTOR.
Examples:
gt dog done alpha # Explicit dog name
gt dog done # Auto-detect from BD_ACTOR (e.g., "deacon/dogs/alpha")`,
RunE: runDogDone,
}
func init() {
// List flags
dogListCmd.Flags().BoolVar(&dogListJSON, "json", false, "Output as JSON")
@@ -228,7 +212,6 @@ func init() {
dogCmd.AddCommand(dogCallCmd)
dogCmd.AddCommand(dogStatusCmd)
dogCmd.AddCommand(dogDispatchCmd)
dogCmd.AddCommand(dogDoneCmd)
rootCmd.AddCommand(dogCmd)
}
@@ -517,34 +500,6 @@ func runDogStatus(cmd *cobra.Command, args []string) error {
return showPackStatus(mgr)
}
func runDogDone(cmd *cobra.Command, args []string) error {
mgr, err := getDogManager()
if err != nil {
return err
}
var name string
if len(args) > 0 {
name = args[0]
} else {
// Try to detect from BD_ACTOR (e.g., "deacon/dogs/alpha")
actor := os.Getenv("BD_ACTOR")
if actor != "" && strings.HasPrefix(actor, "deacon/dogs/") {
name = strings.TrimPrefix(actor, "deacon/dogs/")
}
if name == "" {
return fmt.Errorf("no dog name provided and could not detect from BD_ACTOR")
}
}
if err := mgr.ClearWork(name); err != nil {
return fmt.Errorf("marking dog %s as done: %w", name, err)
}
fmt.Printf("✓ %s marked as idle (ready for new work)\n", name)
return nil
}
func showDogStatus(mgr *dog.Manager, name string) error {
d, err := mgr.Get(name)
if err != nil {
@@ -836,35 +791,6 @@ func runDogDispatch(cmd *cobra.Command, args []string) error {
return fmt.Errorf("sending plugin mail to dog: %w", err)
}
// Spawn a session for the dog to execute the work.
// Without a session, the dog's mail inbox is never checked.
// See: https://github.com/steveyegge/gastown/issues/XXX (dog dispatch doesn't execute)
t := tmux.NewTmux()
townName, err := workspace.GetTownName(townRoot)
if err != nil {
townName = "gt" // fallback
}
dogSessionName := fmt.Sprintf("gt-%s-deacon-%s", townName, targetDog.Name)
// Kill any stale session first
if has, _ := t.HasSession(dogSessionName); has {
_ = t.KillSessionWithProcesses(dogSessionName)
}
// Build startup command with initial prompt to check mail and execute plugin
// Use BuildDogStartupCommand to properly set BD_ACTOR=deacon/dogs/<name> in the startup command
initialPrompt := fmt.Sprintf("I am dog %s. Check my mail inbox with 'gt mail inbox' and execute the plugin instructions I received.", targetDog.Name)
startCmd := config.BuildDogStartupCommand(targetDog.Name, townRoot, targetDog.Path, initialPrompt)
// Create session from dog's directory
if err := t.NewSessionWithCommand(dogSessionName, targetDog.Path, startCmd); err != nil {
if !dogDispatchJSON {
fmt.Printf(" Warning: could not spawn dog session: %v\n", err)
}
// Non-fatal: mail was sent, dog is marked as working, but no session to execute
// The deacon or human can manually start the session later
}
// Success - output result
if dogDispatchJSON {
return json.NewEncoder(os.Stdout).Encode(result)

View File

@@ -129,13 +129,6 @@ func detectSenderFromRole(role string) string {
return fmt.Sprintf("%s/refinery", rig)
}
return detectSenderFromCwd()
case "dog":
// Dogs use BD_ACTOR directly (set by BuildDogStartupCommand)
actor := os.Getenv("BD_ACTOR")
if actor != "" {
return actor
}
return detectSenderFromCwd()
default:
// Unknown role, try cwd detection
return detectSenderFromCwd()

View File

@@ -392,7 +392,7 @@ func runPluginRun(cmd *cobra.Command, args []string) error {
if duration == "" {
duration = "1h" // default
}
count, err := recorder.CountRunsSince(p.Name, duration)
count, err := recorder.CountSuccessfulRunsSince(p.Name, duration)
if err != nil {
// Log warning but continue
fmt.Fprintf(os.Stderr, "Warning: checking gate status: %v\n", err)
@@ -433,13 +433,14 @@ func runPluginRun(cmd *cobra.Command, args []string) error {
fmt.Printf("%s\n", style.Bold.Render("Instructions:"))
fmt.Println(p.Instructions)
// Record the run
// Record the run as "skipped" - we only printed instructions, didn't execute
// Actual execution happens via Deacon patrol or an agent following the instructions
recorder := plugin.NewRecorder(townRoot)
beadID, err := recorder.RecordRun(plugin.PluginRunRecord{
PluginName: p.Name,
RigName: p.RigName,
Result: plugin.ResultSuccess, // Manual runs are marked success
Body: "Manual run via gt plugin run",
Result: plugin.ResultSkipped, // Instructions printed, not executed
Body: "Manual run via gt plugin run (instructions printed, not executed)",
})
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to record run: %v\n", err)

View File

@@ -18,14 +18,8 @@ import (
// outputPrimeContext outputs the role-specific context using templates or fallback.
func outputPrimeContext(ctx RoleContext) error {
// Calculate rig path for template overrides
var rigPath string
if ctx.Rig != "" && ctx.TownRoot != "" {
rigPath = filepath.Join(ctx.TownRoot, ctx.Rig)
}
// Try to use templates with override support
tmpl, err := templates.NewWithOverrides(ctx.TownRoot, rigPath)
// Try to use templates first
tmpl, err := templates.New()
if err != nil {
// Fall back to hardcoded output if templates fail
return outputPrimeContextFallback(ctx)

View File

@@ -61,11 +61,6 @@ func AgentEnv(cfg AgentEnvConfig) map[string]string {
env["GIT_AUTHOR_NAME"] = "boot"
env["GIT_AUTHOR_EMAIL"] = "boot@gastown.local"
case "dog":
env["BD_ACTOR"] = fmt.Sprintf("deacon/dogs/%s", cfg.AgentName)
env["GIT_AUTHOR_NAME"] = fmt.Sprintf("dog-%s", cfg.AgentName)
env["GIT_AUTHOR_EMAIL"] = fmt.Sprintf("dog-%s@gastown.local", cfg.AgentName)
case "witness":
env["GT_RIG"] = cfg.Rig
env["BD_ACTOR"] = fmt.Sprintf("%s/witness", cfg.Rig)
@@ -133,7 +128,7 @@ func AgentEnvSimple(role, rig, agentName string) map[string]string {
// ShellQuote returns a shell-safe quoted string.
// Values containing special characters are wrapped in single quotes.
// Single quotes within the value are escaped using the '\ idiom.
// Single quotes within the value are escaped using the '\'' idiom.
func ShellQuote(s string) string {
// Check if quoting is needed (contains shell special chars)
needsQuoting := false

View File

@@ -125,23 +125,6 @@ func TestAgentEnv_Boot(t *testing.T) {
assertNotSet(t, env, "BEADS_NO_DAEMON")
}
func TestAgentEnv_Dog(t *testing.T) {
t.Parallel()
env := AgentEnv(AgentEnvConfig{
Role: "dog",
AgentName: "alpha",
TownRoot: "/town",
})
assertEnv(t, env, "GT_ROLE", "dog")
assertEnv(t, env, "BD_ACTOR", "deacon/dogs/alpha")
assertEnv(t, env, "GIT_AUTHOR_NAME", "dog-alpha")
assertEnv(t, env, "GIT_AUTHOR_EMAIL", "dog-alpha@gastown.local")
assertEnv(t, env, "GT_ROOT", "/town")
assertNotSet(t, env, "GT_RIG")
assertNotSet(t, env, "BEADS_NO_DAEMON")
}
func TestAgentEnv_WithRuntimeConfigDir(t *testing.T) {
t.Parallel()
env := AgentEnv(AgentEnvConfig{

View File

@@ -1457,17 +1457,6 @@ func BuildPolecatStartupCommandWithAgentOverride(rigName, polecatName, rigPath,
return BuildStartupCommandWithAgentOverride(envVars, rigPath, prompt, agentOverride)
}
// BuildDogStartupCommand builds the startup command for a deacon dog.
// Sets GT_ROLE, BD_ACTOR, GIT_AUTHOR_NAME, and GT_ROOT.
func BuildDogStartupCommand(dogName, townRoot, dogPath, prompt string) string {
envVars := AgentEnv(AgentEnvConfig{
Role: "dog",
AgentName: dogName,
TownRoot: townRoot,
})
return BuildStartupCommand(envVars, dogPath, prompt)
}
// BuildCrewStartupCommand builds the startup command for a crew member.
// Sets GT_ROLE, GT_RIG, GT_CREW, BD_ACTOR, GIT_AUTHOR_NAME, and GT_ROOT.
func BuildCrewStartupCommand(rigName, crewName, rigPath, prompt string) string {

View File

@@ -213,3 +213,19 @@ func (r *Recorder) CountRunsSince(pluginName string, since string) (int, error)
}
return len(runs), nil
}
// CountSuccessfulRunsSince returns the count of successful runs for a plugin since the given duration.
// Only successful runs count for cooldown gate evaluation - skipped/failed runs don't reset the cooldown.
func (r *Recorder) CountSuccessfulRunsSince(pluginName string, since string) (int, error) {
runs, err := r.GetRunsSince(pluginName, since)
if err != nil {
return 0, err
}
count := 0
for _, run := range runs {
if run.Result == ResultSuccess {
count++
}
}
return count, nil
}

View File

@@ -20,25 +20,22 @@ var commandsFS embed.FS
type Templates struct {
roleTemplates *template.Template
messageTemplates *template.Template
// roleOverrides maps role name to override template content.
// When set, these are used instead of embedded templates.
roleOverrides map[string]*template.Template
}
// RoleData contains information for rendering role contexts.
type RoleData struct {
Role string // mayor, witness, refinery, polecat, crew, deacon
RigName string // e.g., "greenplace"
TownRoot string // e.g., "/Users/steve/ai"
TownName string // e.g., "ai" - the town identifier for session names
WorkDir string // current working directory
DefaultBranch string // default branch for merges (e.g., "main", "develop")
Polecat string // polecat name (for polecat role)
Polecats []string // list of polecats (for witness role)
BeadsDir string // BEADS_DIR path
IssuePrefix string // beads issue prefix
MayorSession string // e.g., "gt-ai-mayor" - dynamic mayor session name
DeaconSession string // e.g., "gt-ai-deacon" - dynamic deacon session name
Role string // mayor, witness, refinery, polecat, crew, deacon
RigName string // e.g., "greenplace"
TownRoot string // e.g., "/Users/steve/ai"
TownName string // e.g., "ai" - the town identifier for session names
WorkDir string // current working directory
DefaultBranch string // default branch for merges (e.g., "main", "develop")
Polecat string // polecat name (for polecat role)
Polecats []string // list of polecats (for witness role)
BeadsDir string // BEADS_DIR path
IssuePrefix string // beads issue prefix
MayorSession string // e.g., "gt-ai-mayor" - dynamic mayor session name
DeaconSession string // e.g., "gt-ai-deacon" - dynamic deacon session name
}
// SpawnData contains information for spawn assignment messages.
@@ -84,24 +81,11 @@ type HandoffData struct {
GitDirty bool
}
// New creates a new Templates instance with only embedded templates.
// New creates a new Templates instance.
func New() (*Templates, error) {
return NewWithOverrides("", "")
}
t := &Templates{}
// NewWithOverrides creates a Templates instance that checks for local overrides.
// Override resolution order (later overrides earlier):
// 1. Built-in templates (embedded in binary)
// 2. Town-level overrides (<townRoot>/templates/roles/<role>.md.tmpl)
// 3. Rig-level overrides (<rigPath>/templates/roles/<role>.md.tmpl)
//
// If townRoot or rigPath is empty, that level is skipped.
func NewWithOverrides(townRoot, rigPath string) (*Templates, error) {
t := &Templates{
roleOverrides: make(map[string]*template.Template),
}
// Parse embedded role templates
// Parse role templates
roleTempl, err := template.ParseFS(templateFS, "roles/*.md.tmpl")
if err != nil {
return nil, fmt.Errorf("parsing role templates: %w", err)
@@ -115,51 +99,14 @@ func NewWithOverrides(townRoot, rigPath string) (*Templates, error) {
}
t.messageTemplates = msgTempl
// Load role overrides from filesystem
// Check both town and rig levels, rig takes precedence
roles := []string{"mayor", "witness", "refinery", "polecat", "crew", "deacon"}
overridePaths := []string{}
if townRoot != "" {
overridePaths = append(overridePaths, filepath.Join(townRoot, "templates", "roles"))
}
if rigPath != "" {
overridePaths = append(overridePaths, filepath.Join(rigPath, "templates", "roles"))
}
for _, role := range roles {
templateName := role + ".md.tmpl"
// Check each override path in order (later paths override earlier)
for _, overrideDir := range overridePaths {
overridePath := filepath.Join(overrideDir, templateName)
if content, err := os.ReadFile(overridePath); err == nil {
tmpl, err := template.New(templateName).Parse(string(content))
if err != nil {
return nil, fmt.Errorf("parsing override template %s: %w", overridePath, err)
}
t.roleOverrides[role] = tmpl
}
}
}
return t, nil
}
// RenderRole renders a role context template.
// If an override template exists for the role, it is used instead of the embedded template.
func (t *Templates) RenderRole(role string, data RoleData) (string, error) {
templateName := role + ".md.tmpl"
var buf bytes.Buffer
// Check for override template first
if override, ok := t.roleOverrides[role]; ok {
if err := override.Execute(&buf, data); err != nil {
return "", fmt.Errorf("rendering override template %s: %w", templateName, err)
}
return buf.String(), nil
}
// Fall back to embedded template
if err := t.roleTemplates.ExecuteTemplate(&buf, templateName, data); err != nil {
return "", fmt.Errorf("rendering role template %s: %w", templateName, err)
}
@@ -184,17 +131,6 @@ func (t *Templates) RoleNames() []string {
return []string{"mayor", "witness", "refinery", "polecat", "crew", "deacon"}
}
// HasRoleOverride returns true if the given role has a local override template.
func (t *Templates) HasRoleOverride(role string) bool {
_, ok := t.roleOverrides[role]
return ok
}
// RoleOverrideCount returns the number of role templates that have local overrides.
func (t *Templates) RoleOverrideCount() int {
return len(t.roleOverrides)
}
// MessageNames returns the list of available message templates.
func (t *Templates) MessageNames() []string {
return []string{"spawn", "nudge", "escalation", "handoff"}

View File

@@ -1,7 +1,6 @@
package templates
import (
"os"
"strings"
"testing"
)
@@ -296,164 +295,3 @@ func TestGetAllRoleTemplates_ContentValidity(t *testing.T) {
}
}
}
func TestNewWithOverrides_NoOverrides(t *testing.T) {
// When no override paths exist, should work like New()
tmpl, err := NewWithOverrides("/nonexistent/town", "/nonexistent/rig")
if err != nil {
t.Fatalf("NewWithOverrides() error = %v", err)
}
if tmpl == nil {
t.Fatal("NewWithOverrides() returned nil")
}
// Should have no overrides
if tmpl.RoleOverrideCount() != 0 {
t.Errorf("RoleOverrideCount() = %d, want 0", tmpl.RoleOverrideCount())
}
// Should still render embedded templates
data := RoleData{
Role: "polecat",
RigName: "myrig",
Polecat: "TestCat",
}
output, err := tmpl.RenderRole("polecat", data)
if err != nil {
t.Fatalf("RenderRole() error = %v", err)
}
if !strings.Contains(output, "Polecat Context") {
t.Error("embedded template not rendered correctly")
}
}
func TestNewWithOverrides_WithOverride(t *testing.T) {
// Create a temp directory with an override template
tempDir := t.TempDir()
overrideDir := tempDir + "/templates/roles"
if err := os.MkdirAll(overrideDir, 0755); err != nil {
t.Fatalf("failed to create override dir: %v", err)
}
// Write a simple override template
overrideContent := `# Custom Polecat Context
You are polecat {{ .Polecat }} with custom instructions.
Rig: {{ .RigName }}
`
if err := os.WriteFile(overrideDir+"/polecat.md.tmpl", []byte(overrideContent), 0644); err != nil {
t.Fatalf("failed to write override template: %v", err)
}
// Create Templates with the override
tmpl, err := NewWithOverrides("", tempDir)
if err != nil {
t.Fatalf("NewWithOverrides() error = %v", err)
}
// Should have one override
if tmpl.RoleOverrideCount() != 1 {
t.Errorf("RoleOverrideCount() = %d, want 1", tmpl.RoleOverrideCount())
}
// Should report polecat as having override
if !tmpl.HasRoleOverride("polecat") {
t.Error("HasRoleOverride('polecat') = false, want true")
}
if tmpl.HasRoleOverride("mayor") {
t.Error("HasRoleOverride('mayor') = true, want false")
}
// Render should use override
data := RoleData{
Role: "polecat",
RigName: "myrig",
Polecat: "TestCat",
}
output, err := tmpl.RenderRole("polecat", data)
if err != nil {
t.Fatalf("RenderRole() error = %v", err)
}
// Should contain custom content, not embedded
if !strings.Contains(output, "Custom Polecat Context") {
t.Error("override template not used - missing 'Custom Polecat Context'")
}
if strings.Contains(output, "Idle Polecat Heresy") {
t.Error("embedded template used instead of override")
}
if !strings.Contains(output, "TestCat") {
t.Error("template variable not expanded")
}
}
func TestNewWithOverrides_RigOverridesTown(t *testing.T) {
// Create temp dirs for both town and rig overrides
townDir := t.TempDir()
rigDir := t.TempDir()
townOverrideDir := townDir + "/templates/roles"
rigOverrideDir := rigDir + "/templates/roles"
if err := os.MkdirAll(townOverrideDir, 0755); err != nil {
t.Fatalf("failed to create town override dir: %v", err)
}
if err := os.MkdirAll(rigOverrideDir, 0755); err != nil {
t.Fatalf("failed to create rig override dir: %v", err)
}
// Write town override
townContent := "# Town Polecat\nTown override: {{ .Polecat }}"
if err := os.WriteFile(townOverrideDir+"/polecat.md.tmpl", []byte(townContent), 0644); err != nil {
t.Fatalf("failed to write town override: %v", err)
}
// Write rig override (should win)
rigContent := "# Rig Polecat\nRig override: {{ .Polecat }}"
if err := os.WriteFile(rigOverrideDir+"/polecat.md.tmpl", []byte(rigContent), 0644); err != nil {
t.Fatalf("failed to write rig override: %v", err)
}
// Create Templates with both overrides
tmpl, err := NewWithOverrides(townDir, rigDir)
if err != nil {
t.Fatalf("NewWithOverrides() error = %v", err)
}
// Render should use rig override (higher precedence)
data := RoleData{Polecat: "TestCat"}
output, err := tmpl.RenderRole("polecat", data)
if err != nil {
t.Fatalf("RenderRole() error = %v", err)
}
if !strings.Contains(output, "Rig Polecat") {
t.Error("rig override not used")
}
if strings.Contains(output, "Town Polecat") {
t.Error("town override used instead of rig override")
}
}
func TestNewWithOverrides_InvalidTemplate(t *testing.T) {
// Create a temp directory with an invalid template
tempDir := t.TempDir()
overrideDir := tempDir + "/templates/roles"
if err := os.MkdirAll(overrideDir, 0755); err != nil {
t.Fatalf("failed to create override dir: %v", err)
}
// Write an invalid template (unclosed action)
invalidContent := "# Invalid Template\n{{ .Polecat"
if err := os.WriteFile(overrideDir+"/polecat.md.tmpl", []byte(invalidContent), 0644); err != nil {
t.Fatalf("failed to write invalid template: %v", err)
}
// Should return error for invalid template
_, err := NewWithOverrides("", tempDir)
if err == nil {
t.Error("NewWithOverrides() should fail with invalid template")
}
if !strings.Contains(err.Error(), "parsing override template") {
t.Errorf("error should mention parsing override template: %v", err)
}
}