fix(plugin): don't record false success for manual plugin runs #4

Open
johno wants to merge 5 commits from dog/charlie-gastown-1769245500525 into main
7 changed files with 48 additions and 61 deletions
-51
View File
@@ -1,51 +0,0 @@
name: Block Internal PRs
on:
pull_request:
types: [opened, reopened]
jobs:
block-internal-prs:
name: Block Internal PRs
# Only run if PR is from the same repo (not a fork)
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- name: Close PR and comment
uses: actions/github-script@v7
with:
script: |
const prNumber = context.issue.number;
const branch = context.payload.pull_request.head.ref;
const body = [
'**Internal PRs are not allowed.**',
'',
'Gas Town agents push directly to main. PRs are for external contributors only.',
'',
'To land your changes:',
'```bash',
'git checkout main',
'git merge ' + branch,
'git push origin main',
'git push origin --delete ' + branch,
'```',
'',
'See CLAUDE.md: "Crew workers push directly to main. No feature branches. NEVER create PRs."'
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: body
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
state: 'closed'
});
core.setFailed('Internal PR blocked. Push directly to main instead.');
+5 -4
View File
@@ -392,7 +392,7 @@ func runPluginRun(cmd *cobra.Command, args []string) error {
if duration == "" { if duration == "" {
duration = "1h" // default duration = "1h" // default
} }
count, err := recorder.CountRunsSince(p.Name, duration) count, err := recorder.CountSuccessfulRunsSince(p.Name, duration)
if err != nil { if err != nil {
// Log warning but continue // Log warning but continue
fmt.Fprintf(os.Stderr, "Warning: checking gate status: %v\n", err) 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.Printf("%s\n", style.Bold.Render("Instructions:"))
fmt.Println(p.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) recorder := plugin.NewRecorder(townRoot)
beadID, err := recorder.RecordRun(plugin.PluginRunRecord{ beadID, err := recorder.RecordRun(plugin.PluginRunRecord{
PluginName: p.Name, PluginName: p.Name,
RigName: p.RigName, RigName: p.RigName,
Result: plugin.ResultSuccess, // Manual runs are marked success Result: plugin.ResultSkipped, // Instructions printed, not executed
Body: "Manual run via gt plugin run", Body: "Manual run via gt plugin run (instructions printed, not executed)",
}) })
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to record run: %v\n", err) fmt.Fprintf(os.Stderr, "Warning: failed to record run: %v\n", err)
+7
View File
@@ -49,36 +49,43 @@ func AgentEnv(cfg AgentEnvConfig) map[string]string {
case "mayor": case "mayor":
env["BD_ACTOR"] = "mayor" env["BD_ACTOR"] = "mayor"
env["GIT_AUTHOR_NAME"] = "mayor" env["GIT_AUTHOR_NAME"] = "mayor"
env["GIT_AUTHOR_EMAIL"] = "mayor@gastown.local"
case "deacon": case "deacon":
env["BD_ACTOR"] = "deacon" env["BD_ACTOR"] = "deacon"
env["GIT_AUTHOR_NAME"] = "deacon" env["GIT_AUTHOR_NAME"] = "deacon"
env["GIT_AUTHOR_EMAIL"] = "deacon@gastown.local"
case "boot": case "boot":
env["BD_ACTOR"] = "deacon-boot" env["BD_ACTOR"] = "deacon-boot"
env["GIT_AUTHOR_NAME"] = "boot" env["GIT_AUTHOR_NAME"] = "boot"
env["GIT_AUTHOR_EMAIL"] = "boot@gastown.local"
case "witness": case "witness":
env["GT_RIG"] = cfg.Rig env["GT_RIG"] = cfg.Rig
env["BD_ACTOR"] = fmt.Sprintf("%s/witness", cfg.Rig) env["BD_ACTOR"] = fmt.Sprintf("%s/witness", cfg.Rig)
env["GIT_AUTHOR_NAME"] = fmt.Sprintf("%s/witness", cfg.Rig) env["GIT_AUTHOR_NAME"] = fmt.Sprintf("%s/witness", cfg.Rig)
env["GIT_AUTHOR_EMAIL"] = fmt.Sprintf("%s-witness@gastown.local", cfg.Rig)
case "refinery": case "refinery":
env["GT_RIG"] = cfg.Rig env["GT_RIG"] = cfg.Rig
env["BD_ACTOR"] = fmt.Sprintf("%s/refinery", cfg.Rig) env["BD_ACTOR"] = fmt.Sprintf("%s/refinery", cfg.Rig)
env["GIT_AUTHOR_NAME"] = fmt.Sprintf("%s/refinery", cfg.Rig) env["GIT_AUTHOR_NAME"] = fmt.Sprintf("%s/refinery", cfg.Rig)
env["GIT_AUTHOR_EMAIL"] = fmt.Sprintf("%s-refinery@gastown.local", cfg.Rig)
case "polecat": case "polecat":
env["GT_RIG"] = cfg.Rig env["GT_RIG"] = cfg.Rig
env["GT_POLECAT"] = cfg.AgentName env["GT_POLECAT"] = cfg.AgentName
env["BD_ACTOR"] = fmt.Sprintf("%s/polecats/%s", cfg.Rig, cfg.AgentName) env["BD_ACTOR"] = fmt.Sprintf("%s/polecats/%s", cfg.Rig, cfg.AgentName)
env["GIT_AUTHOR_NAME"] = cfg.AgentName env["GIT_AUTHOR_NAME"] = cfg.AgentName
env["GIT_AUTHOR_EMAIL"] = fmt.Sprintf("%s-polecat-%s@gastown.local", cfg.Rig, cfg.AgentName)
case "crew": case "crew":
env["GT_RIG"] = cfg.Rig env["GT_RIG"] = cfg.Rig
env["GT_CREW"] = cfg.AgentName env["GT_CREW"] = cfg.AgentName
env["BD_ACTOR"] = fmt.Sprintf("%s/crew/%s", cfg.Rig, cfg.AgentName) env["BD_ACTOR"] = fmt.Sprintf("%s/crew/%s", cfg.Rig, cfg.AgentName)
env["GIT_AUTHOR_NAME"] = cfg.AgentName env["GIT_AUTHOR_NAME"] = cfg.AgentName
env["GIT_AUTHOR_EMAIL"] = fmt.Sprintf("%s-crew-%s@gastown.local", cfg.Rig, cfg.AgentName)
} }
// Only set GT_ROOT if provided // Only set GT_ROOT if provided
+7
View File
@@ -14,6 +14,7 @@ func TestAgentEnv_Mayor(t *testing.T) {
assertEnv(t, env, "GT_ROLE", "mayor") assertEnv(t, env, "GT_ROLE", "mayor")
assertEnv(t, env, "BD_ACTOR", "mayor") assertEnv(t, env, "BD_ACTOR", "mayor")
assertEnv(t, env, "GIT_AUTHOR_NAME", "mayor") assertEnv(t, env, "GIT_AUTHOR_NAME", "mayor")
assertEnv(t, env, "GIT_AUTHOR_EMAIL", "mayor@gastown.local")
assertEnv(t, env, "GT_ROOT", "/town") assertEnv(t, env, "GT_ROOT", "/town")
assertNotSet(t, env, "GT_RIG") assertNotSet(t, env, "GT_RIG")
assertNotSet(t, env, "BEADS_NO_DAEMON") assertNotSet(t, env, "BEADS_NO_DAEMON")
@@ -31,6 +32,7 @@ func TestAgentEnv_Witness(t *testing.T) {
assertEnv(t, env, "GT_RIG", "myrig") assertEnv(t, env, "GT_RIG", "myrig")
assertEnv(t, env, "BD_ACTOR", "myrig/witness") assertEnv(t, env, "BD_ACTOR", "myrig/witness")
assertEnv(t, env, "GIT_AUTHOR_NAME", "myrig/witness") assertEnv(t, env, "GIT_AUTHOR_NAME", "myrig/witness")
assertEnv(t, env, "GIT_AUTHOR_EMAIL", "myrig-witness@gastown.local")
assertEnv(t, env, "GT_ROOT", "/town") assertEnv(t, env, "GT_ROOT", "/town")
} }
@@ -49,6 +51,7 @@ func TestAgentEnv_Polecat(t *testing.T) {
assertEnv(t, env, "GT_POLECAT", "Toast") assertEnv(t, env, "GT_POLECAT", "Toast")
assertEnv(t, env, "BD_ACTOR", "myrig/polecats/Toast") assertEnv(t, env, "BD_ACTOR", "myrig/polecats/Toast")
assertEnv(t, env, "GIT_AUTHOR_NAME", "Toast") assertEnv(t, env, "GIT_AUTHOR_NAME", "Toast")
assertEnv(t, env, "GIT_AUTHOR_EMAIL", "myrig-polecat-Toast@gastown.local")
assertEnv(t, env, "BEADS_AGENT_NAME", "myrig/Toast") assertEnv(t, env, "BEADS_AGENT_NAME", "myrig/Toast")
assertEnv(t, env, "BEADS_NO_DAEMON", "1") assertEnv(t, env, "BEADS_NO_DAEMON", "1")
} }
@@ -68,6 +71,7 @@ func TestAgentEnv_Crew(t *testing.T) {
assertEnv(t, env, "GT_CREW", "emma") assertEnv(t, env, "GT_CREW", "emma")
assertEnv(t, env, "BD_ACTOR", "myrig/crew/emma") assertEnv(t, env, "BD_ACTOR", "myrig/crew/emma")
assertEnv(t, env, "GIT_AUTHOR_NAME", "emma") assertEnv(t, env, "GIT_AUTHOR_NAME", "emma")
assertEnv(t, env, "GIT_AUTHOR_EMAIL", "myrig-crew-emma@gastown.local")
assertEnv(t, env, "BEADS_AGENT_NAME", "myrig/emma") assertEnv(t, env, "BEADS_AGENT_NAME", "myrig/emma")
assertEnv(t, env, "BEADS_NO_DAEMON", "1") assertEnv(t, env, "BEADS_NO_DAEMON", "1")
} }
@@ -85,6 +89,7 @@ func TestAgentEnv_Refinery(t *testing.T) {
assertEnv(t, env, "GT_RIG", "myrig") assertEnv(t, env, "GT_RIG", "myrig")
assertEnv(t, env, "BD_ACTOR", "myrig/refinery") assertEnv(t, env, "BD_ACTOR", "myrig/refinery")
assertEnv(t, env, "GIT_AUTHOR_NAME", "myrig/refinery") assertEnv(t, env, "GIT_AUTHOR_NAME", "myrig/refinery")
assertEnv(t, env, "GIT_AUTHOR_EMAIL", "myrig-refinery@gastown.local")
assertEnv(t, env, "BEADS_NO_DAEMON", "1") assertEnv(t, env, "BEADS_NO_DAEMON", "1")
} }
@@ -98,6 +103,7 @@ func TestAgentEnv_Deacon(t *testing.T) {
assertEnv(t, env, "GT_ROLE", "deacon") assertEnv(t, env, "GT_ROLE", "deacon")
assertEnv(t, env, "BD_ACTOR", "deacon") assertEnv(t, env, "BD_ACTOR", "deacon")
assertEnv(t, env, "GIT_AUTHOR_NAME", "deacon") assertEnv(t, env, "GIT_AUTHOR_NAME", "deacon")
assertEnv(t, env, "GIT_AUTHOR_EMAIL", "deacon@gastown.local")
assertEnv(t, env, "GT_ROOT", "/town") assertEnv(t, env, "GT_ROOT", "/town")
assertNotSet(t, env, "GT_RIG") assertNotSet(t, env, "GT_RIG")
assertNotSet(t, env, "BEADS_NO_DAEMON") assertNotSet(t, env, "BEADS_NO_DAEMON")
@@ -113,6 +119,7 @@ func TestAgentEnv_Boot(t *testing.T) {
assertEnv(t, env, "GT_ROLE", "boot") assertEnv(t, env, "GT_ROLE", "boot")
assertEnv(t, env, "BD_ACTOR", "deacon-boot") assertEnv(t, env, "BD_ACTOR", "deacon-boot")
assertEnv(t, env, "GIT_AUTHOR_NAME", "boot") assertEnv(t, env, "GIT_AUTHOR_NAME", "boot")
assertEnv(t, env, "GIT_AUTHOR_EMAIL", "boot@gastown.local")
assertEnv(t, env, "GT_ROOT", "/town") assertEnv(t, env, "GT_ROOT", "/town")
assertNotSet(t, env, "GT_RIG") assertNotSet(t, env, "GT_RIG")
assertNotSet(t, env, "BEADS_NO_DAEMON") assertNotSet(t, env, "BEADS_NO_DAEMON")
+2 -2
View File
@@ -10,8 +10,8 @@ const (
ShutdownNotifyDelay = 500 * time.Millisecond ShutdownNotifyDelay = 500 * time.Millisecond
// ClaudeStartTimeout is how long to wait for Claude to start in a session. // ClaudeStartTimeout is how long to wait for Claude to start in a session.
// Increased to 60s because Claude can take 30s+ on slower machines. // Increased to 120s because Claude can take 60s+ on slower machines or under load.
ClaudeStartTimeout = 60 * time.Second ClaudeStartTimeout = 120 * time.Second
// ShellReadyTimeout is how long to wait for shell prompt after command. // ShellReadyTimeout is how long to wait for shell prompt after command.
ShellReadyTimeout = 5 * time.Second ShellReadyTimeout = 5 * time.Second
+16
View File
@@ -213,3 +213,19 @@ func (r *Recorder) CountRunsSince(pluginName string, since string) (int, error)
} }
return len(runs), nil 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
}
+11 -4
View File
@@ -35,7 +35,9 @@ drive shaft - if you stall, the whole town stalls.
**Your startup behavior:** **Your startup behavior:**
1. Check hook (`gt hook`) 1. Check hook (`gt hook`)
2. If work is hooked → EXECUTE (no announcement beyond one line, no waiting) 2. If work is hooked → EXECUTE (no announcement beyond one line, no waiting)
3. If hook empty → Check mail, then wait for user instructions 3. If hook empty → Check escalations (`gt escalate list`)
4. Handle any pending escalations (these are urgent items from other agents)
5. Check mail, then wait for user instructions
**Note:** "Hooked" means work assigned to you. This triggers autonomous mode even **Note:** "Hooked" means work assigned to you. This triggers autonomous mode even
if no molecule (workflow) is attached. Don't confuse with "pinned" which is for if no molecule (workflow) is attached. Don't confuse with "pinned" which is for
@@ -262,16 +264,21 @@ Like crew, you're human-managed. But the hook protocol still applies:
gt hook # Shows hooked work (if any) gt hook # Shows hooked work (if any)
# Step 2: Work hooked? → RUN IT # Step 2: Work hooked? → RUN IT
# Hook empty? → Check mail for attached work
# Step 3: Hook empty? → Check escalations (mayor-specific)
gt escalate list # Shows pending escalations from other agents
# Handle any pending escalations - these are urgent items requiring your attention
# Step 4: Check mail for attached work
gt mail inbox gt mail inbox
# If mail contains attached work, hook it: # If mail contains attached work, hook it:
gt mol attach-from-mail <mail-id> gt mol attach-from-mail <mail-id>
# Step 3: Still nothing? Wait for user instructions # Step 5: Still nothing? Wait for user instructions
# You're the Mayor - the human directs your work # You're the Mayor - the human directs your work
``` ```
**Work hooked → Run it. Hook empty → Check mail. Nothing anywhere → Wait for user.** **Work hooked → Run it. Hook empty → Check escalations → Check mail. Nothing anywhere → Wait for user.**
Your hooked work persists across sessions. Handoff mail (🤝 HANDOFF subject) provides context notes. Your hooked work persists across sessions. Handoff mail (🤝 HANDOFF subject) provides context notes.