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

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.');

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

@@ -49,36 +49,43 @@ func AgentEnv(cfg AgentEnvConfig) map[string]string {
case "mayor":
env["BD_ACTOR"] = "mayor"
env["GIT_AUTHOR_NAME"] = "mayor"
env["GIT_AUTHOR_EMAIL"] = "mayor@gastown.local"
case "deacon":
env["BD_ACTOR"] = "deacon"
env["GIT_AUTHOR_NAME"] = "deacon"
env["GIT_AUTHOR_EMAIL"] = "deacon@gastown.local"
case "boot":
env["BD_ACTOR"] = "deacon-boot"
env["GIT_AUTHOR_NAME"] = "boot"
env["GIT_AUTHOR_EMAIL"] = "boot@gastown.local"
case "witness":
env["GT_RIG"] = cfg.Rig
env["BD_ACTOR"] = 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":
env["GT_RIG"] = cfg.Rig
env["BD_ACTOR"] = 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":
env["GT_RIG"] = cfg.Rig
env["GT_POLECAT"] = cfg.AgentName
env["BD_ACTOR"] = fmt.Sprintf("%s/polecats/%s", cfg.Rig, 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":
env["GT_RIG"] = cfg.Rig
env["GT_CREW"] = cfg.AgentName
env["BD_ACTOR"] = fmt.Sprintf("%s/crew/%s", cfg.Rig, 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

View File

@@ -14,6 +14,7 @@ func TestAgentEnv_Mayor(t *testing.T) {
assertEnv(t, env, "GT_ROLE", "mayor")
assertEnv(t, env, "BD_ACTOR", "mayor")
assertEnv(t, env, "GIT_AUTHOR_NAME", "mayor")
assertEnv(t, env, "GIT_AUTHOR_EMAIL", "mayor@gastown.local")
assertEnv(t, env, "GT_ROOT", "/town")
assertNotSet(t, env, "GT_RIG")
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, "BD_ACTOR", "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")
}
@@ -49,6 +51,7 @@ func TestAgentEnv_Polecat(t *testing.T) {
assertEnv(t, env, "GT_POLECAT", "Toast")
assertEnv(t, env, "BD_ACTOR", "myrig/polecats/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_NO_DAEMON", "1")
}
@@ -68,6 +71,7 @@ func TestAgentEnv_Crew(t *testing.T) {
assertEnv(t, env, "GT_CREW", "emma")
assertEnv(t, env, "BD_ACTOR", "myrig/crew/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_NO_DAEMON", "1")
}
@@ -85,6 +89,7 @@ func TestAgentEnv_Refinery(t *testing.T) {
assertEnv(t, env, "GT_RIG", "myrig")
assertEnv(t, env, "BD_ACTOR", "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")
}
@@ -98,6 +103,7 @@ func TestAgentEnv_Deacon(t *testing.T) {
assertEnv(t, env, "GT_ROLE", "deacon")
assertEnv(t, env, "BD_ACTOR", "deacon")
assertEnv(t, env, "GIT_AUTHOR_NAME", "deacon")
assertEnv(t, env, "GIT_AUTHOR_EMAIL", "deacon@gastown.local")
assertEnv(t, env, "GT_ROOT", "/town")
assertNotSet(t, env, "GT_RIG")
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, "BD_ACTOR", "deacon-boot")
assertEnv(t, env, "GIT_AUTHOR_NAME", "boot")
assertEnv(t, env, "GIT_AUTHOR_EMAIL", "boot@gastown.local")
assertEnv(t, env, "GT_ROOT", "/town")
assertNotSet(t, env, "GT_RIG")
assertNotSet(t, env, "BEADS_NO_DAEMON")

View File

@@ -10,8 +10,8 @@ const (
ShutdownNotifyDelay = 500 * time.Millisecond
// ClaudeStartTimeout is how long to wait for Claude to start in a session.
// Increased to 60s because Claude can take 30s+ on slower machines.
ClaudeStartTimeout = 60 * time.Second
// Increased to 120s because Claude can take 60s+ on slower machines or under load.
ClaudeStartTimeout = 120 * time.Second
// ShellReadyTimeout is how long to wait for shell prompt after command.
ShellReadyTimeout = 5 * time.Second

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

@@ -35,7 +35,9 @@ drive shaft - if you stall, the whole town stalls.
**Your startup behavior:**
1. Check hook (`gt hook`)
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
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)
# 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
# If mail contains attached work, hook it:
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
```
**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.