Compare commits
8 Commits
main
...
johno/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e3eb094c5 | ||
|
|
6be7fdd76c | ||
|
|
f0014bb21a | ||
| c9f844b477 | |||
| 2a639ff999 | |||
| eed8941126 | |||
| 9f9b2376ea | |||
| 7fd073810d |
51
.github/workflows/block-internal-prs.yml
vendored
51
.github/workflows/block-internal-prs.yml
vendored
@@ -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.');
|
||||
@@ -182,6 +182,22 @@ 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")
|
||||
@@ -212,6 +228,7 @@ func init() {
|
||||
dogCmd.AddCommand(dogCallCmd)
|
||||
dogCmd.AddCommand(dogStatusCmd)
|
||||
dogCmd.AddCommand(dogDispatchCmd)
|
||||
dogCmd.AddCommand(dogDoneCmd)
|
||||
|
||||
rootCmd.AddCommand(dogCmd)
|
||||
}
|
||||
@@ -500,6 +517,34 @@ 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 {
|
||||
@@ -791,6 +836,35 @@ 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)
|
||||
|
||||
@@ -456,7 +456,7 @@ notifyWitness:
|
||||
|
||||
// Notify dispatcher if work was dispatched by another agent
|
||||
if issueID != "" {
|
||||
if dispatcher := getDispatcherFromBead(cwd, issueID); dispatcher != "" && dispatcher != sender {
|
||||
if dispatcher := getDispatcherFromBead(townRoot, cwd, issueID); dispatcher != "" && dispatcher != sender {
|
||||
dispatcherNotification := &mail.Message{
|
||||
To: dispatcher,
|
||||
From: sender,
|
||||
@@ -645,7 +645,7 @@ func updateAgentStateOnDone(cwd, townRoot, exitType, _ string) { // issueID unus
|
||||
if _, err := bd.Run("agent", "state", agentBeadID, "awaiting-gate"); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: couldn't set agent %s to awaiting-gate: %v\n", agentBeadID, err)
|
||||
}
|
||||
// ExitCompleted and ExitDeferred don't set state - observable from tmux
|
||||
// ExitCompleted and ExitDeferred don't set state - observable from tmux
|
||||
}
|
||||
|
||||
// ZFC #10: Self-report cleanup status
|
||||
@@ -678,12 +678,19 @@ func getIssueFromAgentHook(bd *beads.Beads, agentBeadID string) string {
|
||||
|
||||
// getDispatcherFromBead retrieves the dispatcher agent ID from the bead's attachment fields.
|
||||
// Returns empty string if no dispatcher is recorded.
|
||||
func getDispatcherFromBead(cwd, issueID string) string {
|
||||
//
|
||||
// BUG FIX (sc-g7bl3): Use townRoot and ResolveHookDir for bead lookup instead of
|
||||
// ResolveBeadsDir(cwd). When the polecat's worktree is deleted before gt done finishes,
|
||||
// ResolveBeadsDir(cwd) fails because the redirect file is gone. ResolveHookDir uses
|
||||
// prefix-based routing via routes.jsonl which works regardless of worktree state.
|
||||
func getDispatcherFromBead(townRoot, cwd, issueID string) string {
|
||||
if issueID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
bd := beads.New(beads.ResolveBeadsDir(cwd))
|
||||
// Use ResolveHookDir for resilient bead lookup - works even if worktree is deleted
|
||||
beadsDir := beads.ResolveHookDir(townRoot, issueID, cwd)
|
||||
bd := beads.New(beadsDir)
|
||||
issue, err := bd.Show(issueID)
|
||||
if err != nil {
|
||||
return ""
|
||||
|
||||
@@ -129,6 +129,13 @@ 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()
|
||||
|
||||
@@ -49,36 +49,48 @@ 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 "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)
|
||||
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
|
||||
@@ -121,7 +133,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
|
||||
|
||||
@@ -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,24 @@ 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")
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
@@ -1457,6 +1457,17 @@ 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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user