refactor: remove legacy .beads-wisp infrastructure (gt-5klh)
Wisps are now just a flag on regular beads issues (Wisp=true). No separate directory needed - hooks stored in .beads/. Changes: - wisp package: WispDir now points to .beads/, removed PatrolCycle - manager.go: removed initWispBeads() - no separate dir to create - mrqueue.go: MRs stored in .beads/mq/ instead of .beads-wisp/mq/ - doctor: removed obsolete wisp directory checks - docs: updated wisp-architecture.md to reflect simplified model 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -29,6 +29,3 @@ gt
|
|||||||
# Runtime state
|
# Runtime state
|
||||||
state.json
|
state.json
|
||||||
.runtime/
|
.runtime/
|
||||||
|
|
||||||
# Ephemeral wisps (never tracked)
|
|
||||||
.beads-wisp/
|
|
||||||
|
|||||||
@@ -1,252 +1,108 @@
|
|||||||
# Wisp Architecture: Transient Molecule Storage
|
# Wisp Architecture: Simplified
|
||||||
|
|
||||||
> Status: Design Spec v1 - December 2024
|
> Status: Updated December 2024 - Simplified from separate directory to flag-based
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
**Wisps** are transient molecule execution traces - the "steam" in Gas Town's engine
|
**Wisps** are ephemeral issues - transient workflow state that should not be synced
|
||||||
metaphor. Claude is fire; Claude Code is a Steam engine; Gas Town is a Steam Train,
|
to the shared repository. They're used for operational messages (like lifecycle mail)
|
||||||
with Beads as the tracks. Wisps are steam vapors that dissipate after the work is done.
|
and patrol cycle traces that would otherwise accumulate unbounded.
|
||||||
|
|
||||||
## Core Principle
|
## Core Principle
|
||||||
|
|
||||||
**Wisps are local operational state, not project history.**
|
**Wisps are regular issues with `Wisp: true` flag.**
|
||||||
|
|
||||||
| Artifact | Storage | Git Tracked | Purpose |
|
The old architecture used a separate `.beads-wisp/` directory. This was over-engineered.
|
||||||
|----------|---------|-------------|---------|
|
The simplified approach:
|
||||||
| Issues | `.beads/issues.jsonl` | Yes | Permanent project history |
|
|
||||||
| Wisps | `.beads-wisp/issues.jsonl` | **No** | Transient execution traces |
|
|
||||||
| Digests | `.beads/issues.jsonl` | Yes | Compressed summaries of squashed wisps |
|
|
||||||
|
|
||||||
## Storage Architecture
|
| Old | New |
|
||||||
|
|-----|-----|
|
||||||
|
| `.beads-wisp/issues.jsonl` | `.beads/issues.jsonl` with `Wisp: true` |
|
||||||
|
| Separate directory, git init, gitignore | Single database, filtered on sync |
|
||||||
|
| Complex dual-inbox routing | Simple flag check |
|
||||||
|
|
||||||
### Directory Structure
|
## How It Works
|
||||||
|
|
||||||
```
|
### Creating Wisps
|
||||||
~/gt/gastown/ # Rig root (not a git repo)
|
|
||||||
├── .beads-wisp/ # Shared wisp storage (rig-level, gitignored)
|
```bash
|
||||||
│ └── issues.jsonl # In-progress wisps (Deacon, Witness, Refinery)
|
# Create an ephemeral issue
|
||||||
│
|
bd create --title "Patrol cycle" --wisp
|
||||||
├── mayor/rig/ # Mayor's canonical clone
|
|
||||||
│ └── .beads/ # CANONICAL rig beads (versioned)
|
# Send ephemeral mail (automatically sets Wisp=true)
|
||||||
│ ├── issues.jsonl # Permanent issues + digests
|
gt mail send --wisp -s "Lifecycle: spawn" -m "..."
|
||||||
│ └── config.yaml
|
|
||||||
│
|
|
||||||
├── refinery/rig/ # Refinery's clone
|
|
||||||
│ └── .beads/ # Inherits from mayor/rig
|
|
||||||
│
|
|
||||||
├── witness/ # Witness (no clone needed)
|
|
||||||
│
|
|
||||||
└── polecats/<name>/ # Polecat worktrees
|
|
||||||
└── .beads/ # Inherits from mayor/rig
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Key Points
|
### Sync Filtering
|
||||||
|
|
||||||
1. **`.beads-wisp/` is at rig root** - Outside any git clone, naturally isolated
|
When `bd sync` exports to JSONL for git:
|
||||||
2. **All rig agents share `<rig>/.beads-wisp/`** - Deacon, Witness, Refinery
|
- Issues with `Wisp: true` are **excluded**
|
||||||
3. **Digests go to canonical `.beads/`** - Permanent record after squash
|
- Only permanent issues are synced to remote
|
||||||
4. **Wisps are deleted after squash/burn** - No accumulation
|
- No separate directory needed
|
||||||
5. **Polecats don't use wisps** - Each assignment is a deliverable with audit value
|
|
||||||
|
|
||||||
### Gitignore Entry
|
### Querying
|
||||||
|
|
||||||
Add to `.beads/.gitignore`:
|
```bash
|
||||||
```
|
# List all issues (including wisps)
|
||||||
.beads-wisp/
|
bd list
|
||||||
|
|
||||||
|
# List only wisps
|
||||||
|
bd list --wisp
|
||||||
|
|
||||||
|
# List only permanent issues
|
||||||
|
bd list --no-wisp
|
||||||
```
|
```
|
||||||
|
|
||||||
Or add to rig-level `.gitignore`:
|
## Use Cases
|
||||||
```
|
|
||||||
**/.beads-wisp/
|
### Ephemeral Mail (Lifecycle Messages)
|
||||||
|
|
||||||
|
Spawn notifications, session handoffs, and other operational messages that:
|
||||||
|
- Don't need to be synced to remote
|
||||||
|
- Would accumulate unbounded
|
||||||
|
- Have no long-term audit value
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gt mail send gastown/polecats/nux --wisp \
|
||||||
|
-s "LIFECYCLE: spawn" \
|
||||||
|
-m "Work on issue gt-abc"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Wisp Lifecycle
|
### Patrol Cycle Traces
|
||||||
|
|
||||||
```
|
Deacon, Witness, and Refinery run continuous loops. Each cycle would create
|
||||||
bd mol bond <proto> --wisp
|
accumulating history. Wisps let them track cycle state without permanent records.
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────┐
|
|
||||||
│ .beads-wisp/ │
|
|
||||||
│ └── issues.jsonl │ ← Wisp created here
|
|
||||||
│ └── {id, wisp: true, ...}
|
|
||||||
└────────────┬────────────┘
|
|
||||||
│
|
|
||||||
┌────────┴────────┐
|
|
||||||
▼ ▼
|
|
||||||
bd mol burn bd mol squash
|
|
||||||
│ │
|
|
||||||
▼ ▼
|
|
||||||
(deleted) ┌─────────────────────────┐
|
|
||||||
│ .beads/issues.jsonl │
|
|
||||||
│ └── {id, type: digest} │ ← Digest here
|
|
||||||
└─────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Role Assignments
|
### Hook Files
|
||||||
|
|
||||||
### Roles That Use Wisps
|
Agent hook files (`hook-<agent>.json`) are stored in `.beads/` but are local-only
|
||||||
|
runtime state, not synced. These track what work is assigned to each agent for
|
||||||
|
restart-and-resume.
|
||||||
|
|
||||||
These roles have repetitive/cyclic work that would accumulate without wisps:
|
## Decision Matrix
|
||||||
|
|
||||||
| Role | Molecule | Storage Location | Squash Frequency |
|
|
||||||
|------|----------|------------------|------------------|
|
|
||||||
| **Deacon** | mol-deacon-patrol | `<rig>/.beads-wisp/` | Per cycle |
|
|
||||||
| **Witness** | mol-witness-patrol | `<rig>/.beads-wisp/` | Per cycle |
|
|
||||||
| **Refinery** | mol-refinery-cycle | `<rig>/.beads-wisp/` | Per cycle |
|
|
||||||
|
|
||||||
### Roles That Use Regular Molecules
|
|
||||||
|
|
||||||
These roles do discrete work with audit value:
|
|
||||||
|
|
||||||
| Role | Molecule | Storage | Reason |
|
|
||||||
|------|----------|---------|--------|
|
|
||||||
| **Polecat** | mol-polecat-work | .beads/issues.jsonl | Each assignment is a deliverable |
|
|
||||||
| **Mayor** | (ad-hoc) | .beads/issues.jsonl | Coordination has history value |
|
|
||||||
| **Crew** | (ad-hoc) | .beads/issues.jsonl | User work needs audit trail |
|
|
||||||
|
|
||||||
### Decision Matrix
|
|
||||||
|
|
||||||
| Question | Answer | Use |
|
| Question | Answer | Use |
|
||||||
|----------|--------|-----|
|
|----------|--------|-----|
|
||||||
| Is this work repetitive/cyclic? | Yes | Wisp |
|
| Should this sync to remote? | No | Wisp |
|
||||||
| Does the outcome matter more than the trace? | Yes | Wisp |
|
| Is this operational/lifecycle? | Yes | Wisp |
|
||||||
| Would this accumulate unbounded over time? | Yes | Wisp |
|
| Would this accumulate unbounded? | Yes | Wisp |
|
||||||
| Is this a discrete deliverable? | Yes | Regular Mol |
|
| Does this need audit trail? | Yes | Regular issue |
|
||||||
| Might I need to reference this later? | Yes | Regular Mol |
|
| Might others need to see this? | Yes | Regular issue |
|
||||||
| Does this represent user-requested work? | Yes | Regular Mol |
|
|
||||||
|
|
||||||
## Patrol Pattern
|
## Migration from .beads-wisp/
|
||||||
|
|
||||||
Every role using wisps must implement this pattern:
|
The old `.beads-wisp/` directories can be deleted:
|
||||||
|
|
||||||
```go
|
|
||||||
func patrolCycle() {
|
|
||||||
// 1. Bond wisp molecule
|
|
||||||
mol := bdMolBond("mol-<role>-patrol", "--wisp")
|
|
||||||
|
|
||||||
// 2. Execute cycle steps
|
|
||||||
for _, step := range mol.Steps {
|
|
||||||
executeStep(step)
|
|
||||||
bdMolStep(step.ID, "--complete")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Generate summary (agent cognition)
|
|
||||||
summary := generateCycleSummary()
|
|
||||||
|
|
||||||
// 4. Squash - REQUIRED (this is the cleanup)
|
|
||||||
bdMolSquash(mol.ID, "--summary", summary)
|
|
||||||
// Wisp deleted from .beads-wisp/
|
|
||||||
// Digest created in .beads/issues.jsonl
|
|
||||||
|
|
||||||
// 5. Sleep until next cycle
|
|
||||||
time.Sleep(patrolInterval)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Critical**: Without step 4 (squash), wisps become technical debt.
|
|
||||||
|
|
||||||
## Beads Implementation Requirements
|
|
||||||
|
|
||||||
For this architecture to work, Beads needs:
|
|
||||||
|
|
||||||
### New Commands
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Bond with wisp flag (--ephemeral is an alias)
|
# Remove legacy wisp directories
|
||||||
bd mol bond <proto> --wisp
|
rm -rf ~/gt/.beads-wisp/
|
||||||
# Creates in .beads-wisp/ instead of .beads/
|
rm -rf ~/gt/gastown/.beads-wisp/
|
||||||
|
find ~/gt -type d -name '.beads-wisp' -exec rm -rf {} +
|
||||||
# List wisps
|
|
||||||
bd wisp list
|
|
||||||
# Shows in-progress wisps
|
|
||||||
|
|
||||||
# Garbage collect orphaned wisps
|
|
||||||
bd wisp gc
|
|
||||||
# Cleans up wisps from crashed processes
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Storage Behavior
|
No migration needed - these contained transient data with no long-term value.
|
||||||
|
|
||||||
| Command | With `--wisp` | Without |
|
## Related
|
||||||
|---------|---------------|---------|
|
|
||||||
| `bd mol bond` | Creates in `.beads-wisp/` | Creates in `.beads/` |
|
|
||||||
| `bd mol step` | Updates in wisp store | Updates in permanent |
|
|
||||||
| `bd mol squash` | Deletes from wisp, creates digest in permanent | Creates digest in permanent |
|
|
||||||
| `bd mol burn` | Deletes from wisp | Marks abandoned in permanent |
|
|
||||||
|
|
||||||
### Config
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# .beads/config.yaml
|
|
||||||
wisp:
|
|
||||||
enabled: true
|
|
||||||
directory: ../.beads-wisp # Relative to .beads/
|
|
||||||
auto_gc: true # Clean orphans on bd init
|
|
||||||
```
|
|
||||||
|
|
||||||
## Crash Recovery
|
|
||||||
|
|
||||||
If a patrol crashes mid-cycle:
|
|
||||||
|
|
||||||
1. **Wisp persists in `.beads-wisp/`** - Provides recovery breadcrumb
|
|
||||||
2. **On restart, agent can:**
|
|
||||||
- Resume from last step (if step tracking is granular)
|
|
||||||
- Or burn and start fresh (simpler for patrol loops)
|
|
||||||
3. **`bd wisp gc` cleans orphans** - Wisps older than threshold with no active process
|
|
||||||
|
|
||||||
### Orphan Detection
|
|
||||||
|
|
||||||
A wisp is orphaned if:
|
|
||||||
- `process_id` field exists and process is dead
|
|
||||||
- OR `updated_at` is older than threshold (e.g., 1 hour)
|
|
||||||
- AND molecule is not complete
|
|
||||||
|
|
||||||
## Digest Format
|
|
||||||
|
|
||||||
When a wisp is squashed, the digest captures the outcome:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "gt-xyz.digest-001",
|
|
||||||
"type": "digest",
|
|
||||||
"title": "Deacon patrol cycle @ 2024-12-21T10:30:00Z",
|
|
||||||
"description": "Checked 3 witnesses, 2 refineries. All healthy. Processed 5 mail items.",
|
|
||||||
"parent": "gt-xyz",
|
|
||||||
"squashed_from": "gt-xyz.wisp-001",
|
|
||||||
"created_at": "2024-12-21T10:32:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Digests are queryable:
|
|
||||||
```bash
|
|
||||||
bd list --type=digest --parent=gt-deacon-patrol
|
|
||||||
# Shows all patrol cycle summaries
|
|
||||||
```
|
|
||||||
|
|
||||||
## Migration Path
|
|
||||||
|
|
||||||
For existing Gas Town installations:
|
|
||||||
|
|
||||||
1. **Add `.beads-wisp/` to gitignore** (immediate)
|
|
||||||
2. **Update patrol runners to use `--wisp`** (as patched)
|
|
||||||
3. **No migration of existing data** - Fresh start for wisp storage
|
|
||||||
4. **Optional**: Remove old `.beads-ephemeral/` directories
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
1. **Digest retention**: Should old digests be pruned? How old?
|
|
||||||
2. **Wisp schema**: Do wisps need additional fields (process_id, host, etc.)?
|
|
||||||
3. **Cross-process visibility**: Should `bd wisp list` show all wisps or just current process?
|
|
||||||
|
|
||||||
## Related Documents
|
|
||||||
|
|
||||||
|
- [molecules.md](molecules.md) - Molecule system (wisps can be molecule instances)
|
||||||
- [architecture.md](architecture.md) - Overall Gas Town architecture
|
- [architecture.md](architecture.md) - Overall Gas Town architecture
|
||||||
- [patrol-system-design.md](../../../docs/patrol-system-design.md) - Patrol system design
|
|
||||||
- [molecules.md](molecules.md) - Molecule system details
|
|
||||||
|
|
||||||
## Implementation Tracking
|
|
||||||
|
|
||||||
- **Beads**: bd-kwjh (Wisp storage: transient molecule tracking)
|
|
||||||
- **Gas Town**: gt-3x0z.9 (mol-deacon-patrol uses wisps)
|
|
||||||
|
|||||||
@@ -71,13 +71,6 @@ func runDoctor(cmd *cobra.Command, args []string) error {
|
|||||||
d.Register(doctor.NewLinkedPaneCheck())
|
d.Register(doctor.NewLinkedPaneCheck())
|
||||||
d.Register(doctor.NewThemeCheck())
|
d.Register(doctor.NewThemeCheck())
|
||||||
|
|
||||||
// Wisp storage checks
|
|
||||||
d.Register(doctor.NewWispExistsCheck())
|
|
||||||
d.Register(doctor.NewWispGitCheck())
|
|
||||||
d.Register(doctor.NewWispOrphansCheck())
|
|
||||||
d.Register(doctor.NewWispSizeCheck())
|
|
||||||
d.Register(doctor.NewWispStaleCheck())
|
|
||||||
|
|
||||||
// Patrol system checks
|
// Patrol system checks
|
||||||
d.Register(doctor.NewPatrolMoleculesExistCheck())
|
d.Register(doctor.NewPatrolMoleculesExistCheck())
|
||||||
d.Register(doctor.NewPatrolHooksWiredCheck())
|
d.Register(doctor.NewPatrolHooksWiredCheck())
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ LIFECYCLE:
|
|||||||
▼ instantiate/bond
|
▼ instantiate/bond
|
||||||
┌─────────────────┐
|
┌─────────────────┐
|
||||||
│ Mol (durable) │ ← tracked in .beads/
|
│ Mol (durable) │ ← tracked in .beads/
|
||||||
│ Wisp (ephemeral)│ ← tracked in .beads-wisp/
|
│ Wisp (ephemeral)│ ← tracked in .beads/ with Wisp=true
|
||||||
└────────┬────────┘
|
└────────┬────────┘
|
||||||
│
|
│
|
||||||
┌──────┴──────┐
|
┌──────┴──────┐
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ var rigAddCmd = &cobra.Command{
|
|||||||
This creates a rig container with:
|
This creates a rig container with:
|
||||||
- config.json Rig configuration
|
- config.json Rig configuration
|
||||||
- .beads/ Rig-level issue tracking (initialized)
|
- .beads/ Rig-level issue tracking (initialized)
|
||||||
- .beads-wisp/ Local wisp/molecule tracking (gitignored)
|
|
||||||
- plugins/ Rig-level plugin directory
|
- plugins/ Rig-level plugin directory
|
||||||
- refinery/rig/ Canonical main clone
|
- refinery/rig/ Canonical main clone
|
||||||
- mayor/rig/ Mayor's working clone
|
- mayor/rig/ Mayor's working clone
|
||||||
@@ -218,7 +217,6 @@ func runRigAdd(cmd *cobra.Command, args []string) error {
|
|||||||
fmt.Printf(" %s/\n", name)
|
fmt.Printf(" %s/\n", name)
|
||||||
fmt.Printf(" ├── config.json\n")
|
fmt.Printf(" ├── config.json\n")
|
||||||
fmt.Printf(" ├── .beads/ (prefix: %s)\n", newRig.Config.Prefix)
|
fmt.Printf(" ├── .beads/ (prefix: %s)\n", newRig.Config.Prefix)
|
||||||
fmt.Printf(" ├── .beads-wisp/ (local wisp/molecule tracking)\n")
|
|
||||||
fmt.Printf(" ├── plugins/ (rig-level plugins)\n")
|
fmt.Printf(" ├── plugins/ (rig-level plugins)\n")
|
||||||
fmt.Printf(" ├── refinery/rig/ (canonical main)\n")
|
fmt.Printf(" ├── refinery/rig/ (canonical main)\n")
|
||||||
fmt.Printf(" ├── mayor/rig/ (mayor's clone)\n")
|
fmt.Printf(" ├── mayor/rig/ (mayor's clone)\n")
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PatrolMoleculesExistCheck verifies that patrol molecules exist for each rig.
|
// PatrolMoleculesExistCheck verifies that patrol molecules exist for each rig.
|
||||||
@@ -259,8 +261,9 @@ func (c *PatrolNotStuckCheck) Run(ctx *CheckContext) *CheckResult {
|
|||||||
|
|
||||||
var stuckWisps []string
|
var stuckWisps []string
|
||||||
for _, rigName := range rigs {
|
for _, rigName := range rigs {
|
||||||
wispPath := filepath.Join(ctx.TownRoot, rigName, ".beads-wisp", "issues.jsonl")
|
// Check main beads database for wisps (issues with Wisp=true)
|
||||||
stuck := c.checkStuckWisps(wispPath, rigName)
|
beadsPath := filepath.Join(ctx.TownRoot, rigName, ".beads", "issues.jsonl")
|
||||||
|
stuck := c.checkStuckWisps(beadsPath, rigName)
|
||||||
stuckWisps = append(stuckWisps, stuck...)
|
stuckWisps = append(stuckWisps, stuck...)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,3 +460,26 @@ func (c *PatrolRolesHavePromptsCheck) Run(ctx *CheckContext) *CheckResult {
|
|||||||
Message: "All patrol role prompt templates found",
|
Message: "All patrol role prompt templates found",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// discoverRigs finds all registered rigs.
|
||||||
|
func discoverRigs(townRoot string) ([]string, error) {
|
||||||
|
rigsPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
||||||
|
data, err := os.ReadFile(rigsPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil // No rigs configured
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var rigsConfig config.RigsConfig
|
||||||
|
if err := json.Unmarshal(data, &rigsConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var rigs []string
|
||||||
|
for name := range rigsConfig.Rigs {
|
||||||
|
rigs = append(rigs, name)
|
||||||
|
}
|
||||||
|
return rigs, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,519 +1,14 @@
|
|||||||
package doctor
|
package doctor
|
||||||
|
|
||||||
import (
|
// Legacy wisp directory checks removed.
|
||||||
"encoding/json"
|
// Wisps are now just a flag on regular beads issues (Wisp: true).
|
||||||
"fmt"
|
// Hook files are stored in .beads/ alongside other beads data.
|
||||||
"os"
|
//
|
||||||
"os/exec"
|
// These checks were for the old .beads-wisp/ directory infrastructure:
|
||||||
"path/filepath"
|
// - WispExistsCheck: checked if .beads-wisp/ exists
|
||||||
"time"
|
// - WispGitCheck: checked if .beads-wisp/ is a git repo
|
||||||
|
// - WispOrphansCheck: checked for old wisps
|
||||||
"github.com/steveyegge/gastown/internal/config"
|
// - WispSizeCheck: checked size of .beads-wisp/
|
||||||
)
|
// - WispStaleCheck: checked for inactive wisps
|
||||||
|
//
|
||||||
// WispExistsCheck verifies that .beads-wisp/ exists for each rig.
|
// All removed as of the wisp simplification (gt-5klh, bd-bkul).
|
||||||
type WispExistsCheck struct {
|
|
||||||
FixableCheck
|
|
||||||
missingRigs []string // Cached for fix
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWispExistsCheck creates a new wisp exists check.
|
|
||||||
func NewWispExistsCheck() *WispExistsCheck {
|
|
||||||
return &WispExistsCheck{
|
|
||||||
FixableCheck: FixableCheck{
|
|
||||||
BaseCheck: BaseCheck{
|
|
||||||
CheckName: "wisp-exists",
|
|
||||||
CheckDescription: "Check if wisp directory exists for each rig",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run checks if .beads-wisp/ exists for each rig.
|
|
||||||
func (c *WispExistsCheck) Run(ctx *CheckContext) *CheckResult {
|
|
||||||
c.missingRigs = nil // Reset cache
|
|
||||||
|
|
||||||
// Find all rigs
|
|
||||||
rigs, err := c.discoverRigs(ctx.TownRoot)
|
|
||||||
if err != nil {
|
|
||||||
return &CheckResult{
|
|
||||||
Name: c.Name(),
|
|
||||||
Status: StatusError,
|
|
||||||
Message: "Failed to discover rigs",
|
|
||||||
Details: []string{err.Error()},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(rigs) == 0 {
|
|
||||||
return &CheckResult{
|
|
||||||
Name: c.Name(),
|
|
||||||
Status: StatusOK,
|
|
||||||
Message: "No rigs configured",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check each rig
|
|
||||||
var missing []string
|
|
||||||
for _, rigName := range rigs {
|
|
||||||
wispPath := filepath.Join(ctx.TownRoot, rigName, ".beads-wisp")
|
|
||||||
if _, err := os.Stat(wispPath); os.IsNotExist(err) {
|
|
||||||
missing = append(missing, rigName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(missing) > 0 {
|
|
||||||
c.missingRigs = missing
|
|
||||||
return &CheckResult{
|
|
||||||
Name: c.Name(),
|
|
||||||
Status: StatusWarning,
|
|
||||||
Message: fmt.Sprintf("%d rig(s) missing wisp directory", len(missing)),
|
|
||||||
Details: missing,
|
|
||||||
FixHint: "Run 'gt doctor --fix' to create missing directories",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &CheckResult{
|
|
||||||
Name: c.Name(),
|
|
||||||
Status: StatusOK,
|
|
||||||
Message: fmt.Sprintf("All %d rig(s) have wisp directory", len(rigs)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix creates missing .beads-wisp/ directories.
|
|
||||||
func (c *WispExistsCheck) Fix(ctx *CheckContext) error {
|
|
||||||
for _, rigName := range c.missingRigs {
|
|
||||||
wispPath := filepath.Join(ctx.TownRoot, rigName, ".beads-wisp")
|
|
||||||
if err := os.MkdirAll(wispPath, 0755); err != nil {
|
|
||||||
return fmt.Errorf("creating %s: %w", wispPath, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// discoverRigs finds all registered rigs.
|
|
||||||
func (c *WispExistsCheck) discoverRigs(townRoot string) ([]string, error) {
|
|
||||||
rigsPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
|
||||||
data, err := os.ReadFile(rigsPath)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil, nil // No rigs configured
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var rigsConfig config.RigsConfig
|
|
||||||
if err := json.Unmarshal(data, &rigsConfig); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var rigs []string
|
|
||||||
for name := range rigsConfig.Rigs {
|
|
||||||
rigs = append(rigs, name)
|
|
||||||
}
|
|
||||||
return rigs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// WispGitCheck verifies that .beads-wisp/ is a valid git repo.
|
|
||||||
type WispGitCheck struct {
|
|
||||||
FixableCheck
|
|
||||||
invalidRigs []string // Cached for fix
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWispGitCheck creates a new wisp git check.
|
|
||||||
func NewWispGitCheck() *WispGitCheck {
|
|
||||||
return &WispGitCheck{
|
|
||||||
FixableCheck: FixableCheck{
|
|
||||||
BaseCheck: BaseCheck{
|
|
||||||
CheckName: "wisp-git",
|
|
||||||
CheckDescription: "Check if wisp directories are valid git repos",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run checks if .beads-wisp/ directories are valid git repos.
|
|
||||||
func (c *WispGitCheck) Run(ctx *CheckContext) *CheckResult {
|
|
||||||
c.invalidRigs = nil // Reset cache
|
|
||||||
|
|
||||||
// Find all rigs
|
|
||||||
rigs, err := discoverRigs(ctx.TownRoot)
|
|
||||||
if err != nil {
|
|
||||||
return &CheckResult{
|
|
||||||
Name: c.Name(),
|
|
||||||
Status: StatusError,
|
|
||||||
Message: "Failed to discover rigs",
|
|
||||||
Details: []string{err.Error()},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(rigs) == 0 {
|
|
||||||
return &CheckResult{
|
|
||||||
Name: c.Name(),
|
|
||||||
Status: StatusOK,
|
|
||||||
Message: "No rigs configured",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check each rig that has a wisp dir
|
|
||||||
var invalid []string
|
|
||||||
var checked int
|
|
||||||
for _, rigName := range rigs {
|
|
||||||
wispPath := filepath.Join(ctx.TownRoot, rigName, ".beads-wisp")
|
|
||||||
if _, err := os.Stat(wispPath); os.IsNotExist(err) {
|
|
||||||
continue // Skip if directory doesn't exist (handled by wisp-exists)
|
|
||||||
}
|
|
||||||
checked++
|
|
||||||
|
|
||||||
// Check if it's a valid git repo
|
|
||||||
gitDir := filepath.Join(wispPath, ".git")
|
|
||||||
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
|
|
||||||
invalid = append(invalid, rigName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if checked == 0 {
|
|
||||||
return &CheckResult{
|
|
||||||
Name: c.Name(),
|
|
||||||
Status: StatusOK,
|
|
||||||
Message: "No wisp directories to check",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(invalid) > 0 {
|
|
||||||
c.invalidRigs = invalid
|
|
||||||
return &CheckResult{
|
|
||||||
Name: c.Name(),
|
|
||||||
Status: StatusWarning,
|
|
||||||
Message: fmt.Sprintf("%d wisp directory(ies) not initialized as git", len(invalid)),
|
|
||||||
Details: invalid,
|
|
||||||
FixHint: "Run 'gt doctor --fix' to initialize git repos",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &CheckResult{
|
|
||||||
Name: c.Name(),
|
|
||||||
Status: StatusOK,
|
|
||||||
Message: fmt.Sprintf("All %d wisp directories are valid git repos", checked),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix initializes git repos in wisp directories.
|
|
||||||
func (c *WispGitCheck) Fix(ctx *CheckContext) error {
|
|
||||||
for _, rigName := range c.invalidRigs {
|
|
||||||
wispPath := filepath.Join(ctx.TownRoot, rigName, ".beads-wisp")
|
|
||||||
cmd := exec.Command("git", "init")
|
|
||||||
cmd.Dir = wispPath
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("initializing git in %s: %w", wispPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create config.yaml for wisp storage
|
|
||||||
configPath := filepath.Join(wispPath, "config.yaml")
|
|
||||||
configContent := "wisp: true\n# No sync-branch - wisps are local only\n"
|
|
||||||
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
|
||||||
return fmt.Errorf("creating config.yaml in %s: %w", wispPath, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// WispOrphansCheck detects molecules started but never squashed (>24h old).
|
|
||||||
type WispOrphansCheck struct {
|
|
||||||
BaseCheck
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWispOrphansCheck creates a new wisp orphans check.
|
|
||||||
func NewWispOrphansCheck() *WispOrphansCheck {
|
|
||||||
return &WispOrphansCheck{
|
|
||||||
BaseCheck: BaseCheck{
|
|
||||||
CheckName: "wisp-orphans",
|
|
||||||
CheckDescription: "Check for orphaned wisps (>24h old, never squashed)",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run checks for orphaned wisps.
|
|
||||||
func (c *WispOrphansCheck) Run(ctx *CheckContext) *CheckResult {
|
|
||||||
rigs, err := discoverRigs(ctx.TownRoot)
|
|
||||||
if err != nil {
|
|
||||||
return &CheckResult{
|
|
||||||
Name: c.Name(),
|
|
||||||
Status: StatusError,
|
|
||||||
Message: "Failed to discover rigs",
|
|
||||||
Details: []string{err.Error()},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(rigs) == 0 {
|
|
||||||
return &CheckResult{
|
|
||||||
Name: c.Name(),
|
|
||||||
Status: StatusOK,
|
|
||||||
Message: "No rigs configured",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var orphans []string
|
|
||||||
cutoff := time.Now().Add(-24 * time.Hour)
|
|
||||||
|
|
||||||
for _, rigName := range rigs {
|
|
||||||
wispPath := filepath.Join(ctx.TownRoot, rigName, ".beads-wisp")
|
|
||||||
if _, err := os.Stat(wispPath); os.IsNotExist(err) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look for molecule directories or issue files older than 24h
|
|
||||||
issuesPath := filepath.Join(wispPath, "issues.jsonl")
|
|
||||||
info, err := os.Stat(issuesPath)
|
|
||||||
if err != nil {
|
|
||||||
continue // No issues file
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the issues file is old and non-empty
|
|
||||||
if info.ModTime().Before(cutoff) && info.Size() > 0 {
|
|
||||||
orphans = append(orphans, fmt.Sprintf("%s: issues.jsonl last modified %s",
|
|
||||||
rigName, info.ModTime().Format("2006-01-02 15:04")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(orphans) > 0 {
|
|
||||||
return &CheckResult{
|
|
||||||
Name: c.Name(),
|
|
||||||
Status: StatusWarning,
|
|
||||||
Message: fmt.Sprintf("%d rig(s) have stale wisp data (>24h old)", len(orphans)),
|
|
||||||
Details: orphans,
|
|
||||||
FixHint: "Manual review required - these may contain unsquashed work",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &CheckResult{
|
|
||||||
Name: c.Name(),
|
|
||||||
Status: StatusOK,
|
|
||||||
Message: "No orphaned wisps found",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WispSizeCheck warns if wisp repo is too large (>100MB).
|
|
||||||
type WispSizeCheck struct {
|
|
||||||
BaseCheck
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWispSizeCheck creates a new wisp size check.
|
|
||||||
func NewWispSizeCheck() *WispSizeCheck {
|
|
||||||
return &WispSizeCheck{
|
|
||||||
BaseCheck: BaseCheck{
|
|
||||||
CheckName: "wisp-size",
|
|
||||||
CheckDescription: "Check if wisp directories are too large (>100MB)",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run checks the size of wisp directories.
|
|
||||||
func (c *WispSizeCheck) Run(ctx *CheckContext) *CheckResult {
|
|
||||||
rigs, err := discoverRigs(ctx.TownRoot)
|
|
||||||
if err != nil {
|
|
||||||
return &CheckResult{
|
|
||||||
Name: c.Name(),
|
|
||||||
Status: StatusError,
|
|
||||||
Message: "Failed to discover rigs",
|
|
||||||
Details: []string{err.Error()},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(rigs) == 0 {
|
|
||||||
return &CheckResult{
|
|
||||||
Name: c.Name(),
|
|
||||||
Status: StatusOK,
|
|
||||||
Message: "No rigs configured",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxSize = 100 * 1024 * 1024 // 100MB
|
|
||||||
var oversized []string
|
|
||||||
|
|
||||||
for _, rigName := range rigs {
|
|
||||||
wispPath := filepath.Join(ctx.TownRoot, rigName, ".beads-wisp")
|
|
||||||
if _, err := os.Stat(wispPath); os.IsNotExist(err) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
size, err := dirSize(wispPath)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if size > maxSize {
|
|
||||||
oversized = append(oversized, fmt.Sprintf("%s: %s",
|
|
||||||
rigName, formatSize(size)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(oversized) > 0 {
|
|
||||||
return &CheckResult{
|
|
||||||
Name: c.Name(),
|
|
||||||
Status: StatusWarning,
|
|
||||||
Message: fmt.Sprintf("%d rig(s) have oversized wisp directories", len(oversized)),
|
|
||||||
Details: oversized,
|
|
||||||
FixHint: "Consider cleaning up old completed molecules",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &CheckResult{
|
|
||||||
Name: c.Name(),
|
|
||||||
Status: StatusOK,
|
|
||||||
Message: "All wisp directories within size limits",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WispStaleCheck detects molecules with no activity in the last hour.
|
|
||||||
type WispStaleCheck struct {
|
|
||||||
BaseCheck
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWispStaleCheck creates a new wisp stale check.
|
|
||||||
func NewWispStaleCheck() *WispStaleCheck {
|
|
||||||
return &WispStaleCheck{
|
|
||||||
BaseCheck: BaseCheck{
|
|
||||||
CheckName: "wisp-stale",
|
|
||||||
CheckDescription: "Check for stale wisps (no activity in last hour)",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run checks for stale wisps.
|
|
||||||
func (c *WispStaleCheck) Run(ctx *CheckContext) *CheckResult {
|
|
||||||
rigs, err := discoverRigs(ctx.TownRoot)
|
|
||||||
if err != nil {
|
|
||||||
return &CheckResult{
|
|
||||||
Name: c.Name(),
|
|
||||||
Status: StatusError,
|
|
||||||
Message: "Failed to discover rigs",
|
|
||||||
Details: []string{err.Error()},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(rigs) == 0 {
|
|
||||||
return &CheckResult{
|
|
||||||
Name: c.Name(),
|
|
||||||
Status: StatusOK,
|
|
||||||
Message: "No rigs configured",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var stale []string
|
|
||||||
cutoff := time.Now().Add(-1 * time.Hour)
|
|
||||||
|
|
||||||
for _, rigName := range rigs {
|
|
||||||
wispPath := filepath.Join(ctx.TownRoot, rigName, ".beads-wisp")
|
|
||||||
if _, err := os.Stat(wispPath); os.IsNotExist(err) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for any recent activity in the wisp directory
|
|
||||||
// We look at the most recent modification time of any file
|
|
||||||
var mostRecent time.Time
|
|
||||||
_ = filepath.Walk(wispPath, func(path string, info os.FileInfo, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !info.IsDir() && info.ModTime().After(mostRecent) {
|
|
||||||
mostRecent = info.ModTime()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
// If there are files and the most recent is older than 1 hour
|
|
||||||
if !mostRecent.IsZero() && mostRecent.Before(cutoff) {
|
|
||||||
stale = append(stale, fmt.Sprintf("%s: last activity %s ago",
|
|
||||||
rigName, formatDuration(time.Since(mostRecent))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(stale) > 0 {
|
|
||||||
return &CheckResult{
|
|
||||||
Name: c.Name(),
|
|
||||||
Status: StatusWarning,
|
|
||||||
Message: fmt.Sprintf("%d rig(s) have stale wisp activity", len(stale)),
|
|
||||||
Details: stale,
|
|
||||||
FixHint: "Check if polecats are stuck or crashed",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &CheckResult{
|
|
||||||
Name: c.Name(),
|
|
||||||
Status: StatusOK,
|
|
||||||
Message: "No stale wisp activity detected",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
|
|
||||||
// discoverRigs finds all registered rigs (shared helper).
|
|
||||||
func discoverRigs(townRoot string) ([]string, error) {
|
|
||||||
rigsPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
|
||||||
data, err := os.ReadFile(rigsPath)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var rigsConfig config.RigsConfig
|
|
||||||
if err := json.Unmarshal(data, &rigsConfig); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var rigs []string
|
|
||||||
for name := range rigsConfig.Rigs {
|
|
||||||
rigs = append(rigs, name)
|
|
||||||
}
|
|
||||||
return rigs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// dirSize calculates the total size of a directory.
|
|
||||||
func dirSize(path string) (int64, error) {
|
|
||||||
var size int64
|
|
||||||
err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !info.IsDir() {
|
|
||||||
size += info.Size()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return size, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatSize formats bytes as human-readable size.
|
|
||||||
func formatSize(bytes int64) string {
|
|
||||||
const (
|
|
||||||
KB = 1024
|
|
||||||
MB = KB * 1024
|
|
||||||
GB = MB * 1024
|
|
||||||
)
|
|
||||||
switch {
|
|
||||||
case bytes >= GB:
|
|
||||||
return fmt.Sprintf("%.1f GB", float64(bytes)/GB)
|
|
||||||
case bytes >= MB:
|
|
||||||
return fmt.Sprintf("%.1f MB", float64(bytes)/MB)
|
|
||||||
case bytes >= KB:
|
|
||||||
return fmt.Sprintf("%.1f KB", float64(bytes)/KB)
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("%d bytes", bytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatDuration formats a duration as human-readable string.
|
|
||||||
func formatDuration(d time.Duration) string {
|
|
||||||
if d < time.Minute {
|
|
||||||
return fmt.Sprintf("%.0f seconds", d.Seconds())
|
|
||||||
}
|
|
||||||
if d < time.Hour {
|
|
||||||
return fmt.Sprintf("%.0f minutes", d.Minutes())
|
|
||||||
}
|
|
||||||
if d < 24*time.Hour {
|
|
||||||
return fmt.Sprintf("%.1f hours", d.Hours())
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%.1f days", d.Hours()/24)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package mrqueue provides wisp-based merge request queue storage.
|
// Package mrqueue provides merge request queue storage.
|
||||||
// MRs are ephemeral - stored locally in .beads-wisp/mq/ and deleted after merge.
|
// MRs are stored locally in .beads/mq/ and deleted after merge.
|
||||||
// This avoids sync overhead for transient MR state.
|
// This avoids sync overhead for transient MR state.
|
||||||
package mrqueue
|
package mrqueue
|
||||||
|
|
||||||
@@ -28,31 +28,31 @@ type MR struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue manages the MR wisp storage.
|
// Queue manages the MR storage.
|
||||||
type Queue struct {
|
type Queue struct {
|
||||||
dir string // .beads-wisp/mq/ directory
|
dir string // .beads/mq/ directory
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new MR queue for the given rig path.
|
// New creates a new MR queue for the given rig path.
|
||||||
func New(rigPath string) *Queue {
|
func New(rigPath string) *Queue {
|
||||||
return &Queue{
|
return &Queue{
|
||||||
dir: filepath.Join(rigPath, ".beads-wisp", "mq"),
|
dir: filepath.Join(rigPath, ".beads", "mq"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFromWorkdir creates a queue by finding the rig root from a working directory.
|
// NewFromWorkdir creates a queue by finding the rig root from a working directory.
|
||||||
func NewFromWorkdir(workdir string) (*Queue, error) {
|
func NewFromWorkdir(workdir string) (*Queue, error) {
|
||||||
// Walk up to find .beads-wisp or rig root
|
// Walk up to find .beads or rig root
|
||||||
dir := workdir
|
dir := workdir
|
||||||
for {
|
for {
|
||||||
wispDir := filepath.Join(dir, ".beads-wisp")
|
beadsDir := filepath.Join(dir, ".beads")
|
||||||
if info, err := os.Stat(wispDir); err == nil && info.IsDir() {
|
if info, err := os.Stat(beadsDir); err == nil && info.IsDir() {
|
||||||
return &Queue{dir: filepath.Join(wispDir, "mq")}, nil
|
return &Queue{dir: filepath.Join(beadsDir, "mq")}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
parent := filepath.Dir(dir)
|
parent := filepath.Dir(dir)
|
||||||
if parent == dir {
|
if parent == dir {
|
||||||
return nil, fmt.Errorf("could not find .beads-wisp directory from %s", workdir)
|
return nil, fmt.Errorf("could not find .beads directory from %s", workdir)
|
||||||
}
|
}
|
||||||
dir = parent
|
dir = parent
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -281,11 +281,6 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) {
|
|||||||
return nil, fmt.Errorf("initializing beads: %w", err)
|
return nil, fmt.Errorf("initializing beads: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize wisp beads for wisp/molecule tracking
|
|
||||||
if err := m.initWispBeads(rigPath); err != nil {
|
|
||||||
return nil, fmt.Errorf("initializing wisp beads: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seed patrol molecules for this rig
|
// Seed patrol molecules for this rig
|
||||||
if err := m.seedPatrolMolecules(rigPath); err != nil {
|
if err := m.seedPatrolMolecules(rigPath); err != nil {
|
||||||
// Non-fatal: log warning but continue
|
// Non-fatal: log warning but continue
|
||||||
@@ -371,34 +366,6 @@ func (m *Manager) initBeads(rigPath, prefix string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// initWispBeads initializes the wisp beads database at rig level.
|
|
||||||
// Wisp beads are local-only (no sync-branch) and used for runtime tracking
|
|
||||||
// of wisps and molecules.
|
|
||||||
func (m *Manager) initWispBeads(rigPath string) error {
|
|
||||||
beadsDir := filepath.Join(rigPath, ".beads-wisp")
|
|
||||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize as a git repo (for local versioning, not for sync)
|
|
||||||
cmd := exec.Command("git", "init")
|
|
||||||
cmd.Dir = beadsDir
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("git init: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create wisp config (no sync-branch needed)
|
|
||||||
configPath := filepath.Join(beadsDir, "config.yaml")
|
|
||||||
configContent := "wisp: true\n# No sync-branch - wisp is local only\n"
|
|
||||||
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add .beads-wisp/ to .gitignore if not already present
|
|
||||||
gitignorePath := filepath.Join(rigPath, ".gitignore")
|
|
||||||
return m.ensureGitignoreEntry(gitignorePath, ".beads-wisp/")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureGitignoreEntry adds an entry to .gitignore if it doesn't already exist.
|
// ensureGitignoreEntry adds an entry to .gitignore if it doesn't already exist.
|
||||||
func (m *Manager) ensureGitignoreEntry(gitignorePath, entry string) error {
|
func (m *Manager) ensureGitignoreEntry(gitignorePath, entry string) error {
|
||||||
// Read existing content
|
// Read existing content
|
||||||
|
|||||||
@@ -193,52 +193,6 @@ func TestRigSummary(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInitWispBeads(t *testing.T) {
|
|
||||||
root, rigsConfig := setupTestTown(t)
|
|
||||||
manager := NewManager(root, rigsConfig, git.NewGit(root))
|
|
||||||
|
|
||||||
rigPath := filepath.Join(root, "test-rig")
|
|
||||||
if err := os.MkdirAll(rigPath, 0755); err != nil {
|
|
||||||
t.Fatalf("mkdir: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := manager.initWispBeads(rigPath); err != nil {
|
|
||||||
t.Fatalf("initWispBeads: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify directory was created
|
|
||||||
wispPath := filepath.Join(rigPath, ".beads-wisp")
|
|
||||||
if _, err := os.Stat(wispPath); os.IsNotExist(err) {
|
|
||||||
t.Error(".beads-wisp/ was not created")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify it's a git repo
|
|
||||||
gitPath := filepath.Join(wispPath, ".git")
|
|
||||||
if _, err := os.Stat(gitPath); os.IsNotExist(err) {
|
|
||||||
t.Error(".beads-wisp/ was not initialized as git repo")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify config.yaml was created with wisp: true
|
|
||||||
configPath := filepath.Join(wispPath, "config.yaml")
|
|
||||||
content, err := os.ReadFile(configPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reading config.yaml: %v", err)
|
|
||||||
}
|
|
||||||
if string(content) != "wisp: true\n# No sync-branch - wisp is local only\n" {
|
|
||||||
t.Errorf("config.yaml content = %q, want wisp: true with comment", string(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify .gitignore was updated
|
|
||||||
gitignorePath := filepath.Join(rigPath, ".gitignore")
|
|
||||||
ignoreContent, err := os.ReadFile(gitignorePath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reading .gitignore: %v", err)
|
|
||||||
}
|
|
||||||
if string(ignoreContent) != ".beads-wisp/\n" {
|
|
||||||
t.Errorf(".gitignore content = %q, want .beads-wisp/", string(ignoreContent))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureGitignoreEntry_AddsEntry(t *testing.T) {
|
func TestEnsureGitignoreEntry_AddsEntry(t *testing.T) {
|
||||||
root, rigsConfig := setupTestTown(t)
|
root, rigsConfig := setupTestTown(t)
|
||||||
manager := NewManager(root, rigsConfig, git.NewGit(root))
|
manager := NewManager(root, rigsConfig, git.NewGit(root))
|
||||||
|
|||||||
@@ -10,21 +10,21 @@ import (
|
|||||||
|
|
||||||
// Common errors.
|
// Common errors.
|
||||||
var (
|
var (
|
||||||
ErrNoWispDir = errors.New("wisp directory does not exist")
|
ErrNoWispDir = errors.New("beads directory does not exist")
|
||||||
ErrNoHook = errors.New("no hook file found")
|
ErrNoHook = errors.New("no hook file found")
|
||||||
ErrInvalidWisp = errors.New("invalid wisp format")
|
ErrInvalidWisp = errors.New("invalid hook file format")
|
||||||
)
|
)
|
||||||
|
|
||||||
// EnsureDir ensures the .beads-wisp directory exists in the given root.
|
// EnsureDir ensures the .beads directory exists in the given root.
|
||||||
func EnsureDir(root string) (string, error) {
|
func EnsureDir(root string) (string, error) {
|
||||||
dir := filepath.Join(root, WispDir)
|
dir := filepath.Join(root, WispDir)
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
return "", fmt.Errorf("create wisp dir: %w", err)
|
return "", fmt.Errorf("create beads dir: %w", err)
|
||||||
}
|
}
|
||||||
return dir, nil
|
return dir, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WispPath returns the full path to a wisp file.
|
// WispPath returns the full path to a file in the beads directory.
|
||||||
func WispPath(root, filename string) string {
|
func WispPath(root, filename string) string {
|
||||||
return filepath.Join(root, WispDir, filename)
|
return filepath.Join(root, WispDir, filename)
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ func HookPath(root, agent string) string {
|
|||||||
return WispPath(root, HookFilename(agent))
|
return WispPath(root, HookFilename(agent))
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteSlungWork writes a slung work wisp to the agent's hook.
|
// WriteSlungWork writes a slung work hook to the agent's hook file.
|
||||||
func WriteSlungWork(root, agent string, sw *SlungWork) error {
|
func WriteSlungWork(root, agent string, sw *SlungWork) error {
|
||||||
dir, err := EnsureDir(root)
|
dir, err := EnsureDir(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -45,18 +45,6 @@ func WriteSlungWork(root, agent string, sw *SlungWork) error {
|
|||||||
return writeJSON(path, sw)
|
return writeJSON(path, sw)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WritePatrolCycle writes a patrol cycle wisp.
|
|
||||||
func WritePatrolCycle(root, id string, pc *PatrolCycle) error {
|
|
||||||
dir, err := EnsureDir(root)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := "patrol-" + id + ".json"
|
|
||||||
path := filepath.Join(dir, filename)
|
|
||||||
return writeJSON(path, pc)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadHook reads the slung work from an agent's hook file.
|
// ReadHook reads the slung work from an agent's hook file.
|
||||||
// Returns ErrNoHook if no hook file exists.
|
// Returns ErrNoHook if no hook file exists.
|
||||||
func ReadHook(root, agent string) (*SlungWork, error) {
|
func ReadHook(root, agent string) (*SlungWork, error) {
|
||||||
@@ -82,31 +70,6 @@ func ReadHook(root, agent string) (*SlungWork, error) {
|
|||||||
return &sw, nil
|
return &sw, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadPatrolCycle reads a patrol cycle wisp.
|
|
||||||
func ReadPatrolCycle(root, id string) (*PatrolCycle, error) {
|
|
||||||
filename := "patrol-" + id + ".json"
|
|
||||||
path := WispPath(root, filename)
|
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil, ErrNoHook // reuse error for "not found"
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read patrol cycle: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var pc PatrolCycle
|
|
||||||
if err := json.Unmarshal(data, &pc); err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %v", ErrInvalidWisp, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if pc.Type != TypePatrolCycle {
|
|
||||||
return nil, fmt.Errorf("%w: expected patrol-cycle, got %s", ErrInvalidWisp, pc.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &pc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// BurnHook removes an agent's hook file after it has been picked up.
|
// BurnHook removes an agent's hook file after it has been picked up.
|
||||||
func BurnHook(root, agent string) error {
|
func BurnHook(root, agent string) error {
|
||||||
path := HookPath(root, agent)
|
path := HookPath(root, agent)
|
||||||
@@ -117,17 +80,6 @@ func BurnHook(root, agent string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// BurnPatrolCycle removes a patrol cycle wisp.
|
|
||||||
func BurnPatrolCycle(root, id string) error {
|
|
||||||
filename := "patrol-" + id + ".json"
|
|
||||||
path := WispPath(root, filename)
|
|
||||||
err := os.Remove(path)
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil // already burned
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasHook checks if an agent has a hook file.
|
// HasHook checks if an agent has a hook file.
|
||||||
func HasHook(root, agent string) bool {
|
func HasHook(root, agent string) bool {
|
||||||
path := HookPath(root, agent)
|
path := HookPath(root, agent)
|
||||||
|
|||||||
@@ -1,32 +1,30 @@
|
|||||||
// Package wisp provides ephemeral molecule support for Gas Town agents.
|
// Package wisp provides hook file support for Gas Town agents.
|
||||||
//
|
//
|
||||||
// Wisps are short-lived workflow state that lives in .beads-wisp/ and is
|
// Hooks are used to attach work to an agent for restart-and-resume:
|
||||||
// never git-tracked. They are used for:
|
// - hook-<agent>.json files track what bead is assigned to an agent
|
||||||
// - Slung work: attaching a bead to an agent's hook for restart-and-resume
|
// - Created by `gt hook`, `gt sling`, `gt handoff`
|
||||||
// - Patrol cycles: ephemeral state for continuous loops (Deacon, Witness, etc)
|
// - Read on session start to restore work context
|
||||||
|
// - Burned after pickup
|
||||||
//
|
//
|
||||||
// Unlike regular molecules in .beads/, wisps are burned after use.
|
// Hook files live in .beads/ alongside other beads data.
|
||||||
package wisp
|
package wisp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WispType identifies the kind of wisp.
|
// WispType identifies the kind of hook file.
|
||||||
type WispType string
|
type WispType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// TypeSlungWork is a wisp that attaches a bead to an agent's hook.
|
// TypeSlungWork is a hook that attaches a bead to an agent's hook.
|
||||||
// Created by `gt sling <bead-id>` and burned after pickup.
|
// Created by `gt hook`, `gt sling`, or `gt handoff`, and burned after pickup.
|
||||||
TypeSlungWork WispType = "slung-work"
|
TypeSlungWork WispType = "slung-work"
|
||||||
|
|
||||||
// TypePatrolCycle is a wisp tracking patrol execution state.
|
|
||||||
// Used by Deacon, Witness, Refinery for their continuous loops.
|
|
||||||
TypePatrolCycle WispType = "patrol-cycle"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// WispDir is the directory name for ephemeral wisps (not git-tracked).
|
// WispDir is the directory where hook files are stored.
|
||||||
const WispDir = ".beads-wisp"
|
// Hook files (hook-<agent>.json) live alongside other beads data.
|
||||||
|
const WispDir = ".beads"
|
||||||
|
|
||||||
// HookPrefix is the filename prefix for hook files.
|
// HookPrefix is the filename prefix for hook files.
|
||||||
const HookPrefix = "hook-"
|
const HookPrefix = "hook-"
|
||||||
@@ -34,20 +32,20 @@ const HookPrefix = "hook-"
|
|||||||
// HookSuffix is the filename suffix for hook files.
|
// HookSuffix is the filename suffix for hook files.
|
||||||
const HookSuffix = ".json"
|
const HookSuffix = ".json"
|
||||||
|
|
||||||
// Wisp is the common header for all wisp types.
|
// Wisp is the common header for hook files.
|
||||||
type Wisp struct {
|
type Wisp struct {
|
||||||
// Type identifies what kind of wisp this is.
|
// Type identifies what kind of hook file this is.
|
||||||
Type WispType `json:"type"`
|
Type WispType `json:"type"`
|
||||||
|
|
||||||
// CreatedAt is when the wisp was created.
|
// CreatedAt is when the hook was created.
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
||||||
// CreatedBy identifies who created the wisp (e.g., "crew/joe", "deacon").
|
// CreatedBy identifies who created the hook (e.g., "crew/joe", "deacon").
|
||||||
CreatedBy string `json:"created_by"`
|
CreatedBy string `json:"created_by"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SlungWork represents work attached to an agent's hook.
|
// SlungWork represents work attached to an agent's hook.
|
||||||
// Created by `gt sling` and burned after the agent picks it up.
|
// Created by `gt hook`, `gt sling`, or `gt handoff` and burned after pickup.
|
||||||
type SlungWork struct {
|
type SlungWork struct {
|
||||||
Wisp
|
Wisp
|
||||||
|
|
||||||
@@ -61,46 +59,7 @@ type SlungWork struct {
|
|||||||
Subject string `json:"subject,omitempty"`
|
Subject string `json:"subject,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PatrolCycle represents the execution state of a patrol loop.
|
// NewSlungWork creates a new slung work hook file.
|
||||||
// Used by roles that run continuous patrols (Deacon, Witness, Refinery).
|
|
||||||
type PatrolCycle struct {
|
|
||||||
Wisp
|
|
||||||
|
|
||||||
// Formula is the patrol formula being executed (e.g., "mol-deacon-patrol").
|
|
||||||
Formula string `json:"formula"`
|
|
||||||
|
|
||||||
// CurrentStep is the ID of the step currently being executed.
|
|
||||||
CurrentStep string `json:"current_step"`
|
|
||||||
|
|
||||||
// StepStates tracks completion state of each step.
|
|
||||||
StepStates map[string]StepState `json:"step_states,omitempty"`
|
|
||||||
|
|
||||||
// CycleCount tracks how many complete cycles have been run.
|
|
||||||
CycleCount int `json:"cycle_count"`
|
|
||||||
|
|
||||||
// LastCycleAt is when the last complete cycle finished.
|
|
||||||
LastCycleAt *time.Time `json:"last_cycle_at,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// StepState represents the execution state of a single patrol step.
|
|
||||||
type StepState struct {
|
|
||||||
// Status is the current status: pending, in_progress, completed, skipped.
|
|
||||||
Status string `json:"status"`
|
|
||||||
|
|
||||||
// StartedAt is when this step began execution.
|
|
||||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
|
||||||
|
|
||||||
// CompletedAt is when this step finished.
|
|
||||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
|
||||||
|
|
||||||
// Output is optional output from step execution.
|
|
||||||
Output string `json:"output,omitempty"`
|
|
||||||
|
|
||||||
// Error is set if the step failed.
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSlungWork creates a new slung work wisp.
|
|
||||||
func NewSlungWork(beadID, createdBy string) *SlungWork {
|
func NewSlungWork(beadID, createdBy string) *SlungWork {
|
||||||
return &SlungWork{
|
return &SlungWork{
|
||||||
Wisp: Wisp{
|
Wisp: Wisp{
|
||||||
@@ -112,19 +71,6 @@ func NewSlungWork(beadID, createdBy string) *SlungWork {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPatrolCycle creates a new patrol cycle wisp.
|
|
||||||
func NewPatrolCycle(formula, createdBy string) *PatrolCycle {
|
|
||||||
return &PatrolCycle{
|
|
||||||
Wisp: Wisp{
|
|
||||||
Type: TypePatrolCycle,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
CreatedBy: createdBy,
|
|
||||||
},
|
|
||||||
Formula: formula,
|
|
||||||
StepStates: make(map[string]StepState),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HookFilename returns the filename for an agent's hook file.
|
// HookFilename returns the filename for an agent's hook file.
|
||||||
func HookFilename(agent string) string {
|
func HookFilename(agent string) string {
|
||||||
return HookPrefix + agent + HookSuffix
|
return HookPrefix + agent + HookSuffix
|
||||||
|
|||||||
Reference in New Issue
Block a user