diff --git a/dev/issues/0115-worktree-launcher-fn.md b/dev/issues/completed/0115-worktree-launcher-fn.md similarity index 100% rename from dev/issues/0115-worktree-launcher-fn.md rename to dev/issues/completed/0115-worktree-launcher-fn.md diff --git a/docs/capabilities/INDEX.md b/docs/capabilities/INDEX.md index 5514c27c..14274418 100644 --- a/docs/capabilities/INDEX.md +++ b/docs/capabilities/INDEX.md @@ -38,6 +38,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu | [validator](validator.md) | 6 | Funciones que verifican datos/config contra reglas. Pre-flight de sinks y gates en DAGs | | [navegator](navegator.md) | 4 | Automatización de browser via CDP + AX tree + LLM: obtener, limpiar, chunkear AX tree y llamar a Claude CLI | | [cpp-dashboard-viz](cpp-dashboard-viz.md) | 10 | Primitivas C++ ImGui para dashboards: kpi_card, sparkline, line/bar/scatter/pie/heatmap/histogram, panel containers | +| [agents](agents.md) | 3 | Orquestar agentes Claude headless en git worktrees: launch, cleanup, DoD evidence schema audit | ## Como anadir grupo diff --git a/docs/capabilities/agents.md b/docs/capabilities/agents.md new file mode 100644 index 00000000..1673c9b6 --- /dev/null +++ b/docs/capabilities/agents.md @@ -0,0 +1,74 @@ +# Capability group: agents + +Funciones para orquestar agentes Claude headless en git worktrees con DoD (Definition of Done) evidence schema. + +Cubre el ciclo de vida completo de un run autonomo: crear sandbox (worktree + rama), lanzar `claude -p` headless, redirigir logs, hacer cleanup ordenado tras terminar/abortar, y auditar el frontmatter `dod_evidence_schema` que valida que cada run dejo evidencia trazable. + +## Funciones + +| ID | Firma corta | Que hace | +|---|---|---| +| `agent_launch_worktree_go_infra` | `AgentLaunchWorktree(cfg) WorktreeLaunchResult` | crear worktree + spawn claude headless en background | +| `agent_cleanup_worktree_go_infra` | `AgentCleanupWorktree(repo, branch, path, pid) error` | kill PID + remove worktree + delete branch (best-effort) | +| `audit_dod_schema_go_infra` | `AuditDodSchema(issuesDir, flowsDir) Report` | escanea `.md` y valida bloque `dod_evidence_schema` del frontmatter | + +## Ejemplo canonico end-to-end + +```go +package main + +import ( + "fmt" + "log" + + "fn-registry/functions/infra" +) + +func main() { + cfg := infra.WorktreeLaunchConfig{ + RepoRoot: "/home/lucas/fn_registry", + Branch: "auto/0115-worktree-launcher-fn", + WorktreePath: "/home/lucas/fn_registry/worktrees/0115-worktree-launcher-fn", + Prompt: "Implement issue 0115 — worktree launcher Go function", + LogPath: "/tmp/claude-0115.log", + SkipPerms: true, + ResetIfExists: true, + } + + res := infra.AgentLaunchWorktree(cfg) + if res.Error != "" { + log.Fatal(res.Error) + } + fmt.Printf("claude PID=%d log=%s\n", res.PID, res.LogPath) + + // ... agente trabaja, tail del log, polling /proc/, etc ... + + // Cleanup tras terminar/abortar + if err := infra.AgentCleanupWorktree(cfg.RepoRoot, cfg.Branch, cfg.WorktreePath, res.PID); err != nil { + log.Printf("partial cleanup: %v", err) + } + + // Validar DoD del issue antes de cerrarlo + report, _ := infra.AuditDodSchema("/home/lucas/fn_registry/dev/issues", "") + fmt.Printf("dod: %d items, %d invalid\n", report.TotalItems, report.InvalidItems) +} +``` + +## Fronteras + +- **NO maneja merge a master** — eso es responsabilidad de `apps/agent_runner_api` (issue 0113). +- **NO persiste runs** — la tabla `agent_runs.db` vive en `agent_runner_api`, no aqui. +- **NO valida DoD** — `audit_dod_schema_go_infra` solo lee `dod_evidence_schema` y comprueba que cada item tenga id/kind/expected validos. La evidencia real (screenshots, logs, urls) se valida en otra capa. +- **NO genera el prompt** — el caller compone el prompt antes de pasarlo. Para integraciones con `dev/issues/.md`, usar funciones de `dev/` directamente. +- **NO maneja Windows**: `agent_cleanup_worktree` usa `syscall.Kill` (Linux/Darwin). En Windows compila pero el kill no hace nada — documentado en `agent_cleanup_worktree.md`. + +## Prerequisitos + +- `git` >= 2.5 (worktrees). +- Rama `master` (no `main`) en el repo principal — `AgentLaunchWorktree` hace `git worktree add ... -b master`. +- Opcional: binario `claude` en PATH. Si falta, la funcion hace fallback a un `echo` stub que termina inmediatamente — util para tests CI, no para produccion. + +## Notas + +- Las dos funciones operativas (`launch`/`cleanup`) son simetricas — siempre que se llame a una se debe llamar a la otra. `agent_runner_api` lo encadena en un defer. +- `audit_dod_schema` se invoca tambien desde `fn doctor dod` (issue 0114) — la dependencia es bidireccional logicamente, pero del registry no hay imports entre ellas. diff --git a/functions/infra/agent_cleanup_worktree.go b/functions/infra/agent_cleanup_worktree.go new file mode 100644 index 00000000..0caa15d2 --- /dev/null +++ b/functions/infra/agent_cleanup_worktree.go @@ -0,0 +1,59 @@ +package infra + +import ( + "fmt" + "os/exec" + "strings" + "syscall" + "time" +) + +// AgentCleanupWorktree tears down a worktree previously created by +// AgentLaunchWorktree: kills the claude PID (SIGTERM, then SIGKILL after 1s), +// removes the git worktree (force) and deletes the branch. +// +// All three steps are best-effort; we only return an error when ALL three +// fail, so callers can call this on partially-initialised runs safely. +// +// Impure: signals processes, runs git, touches the filesystem. +func AgentCleanupWorktree(repoRoot, branch, worktreePath string, pid int) error { + var killErr, wtErr, brErr error + + // 1. Kill the process tree if a PID was provided. + if pid > 0 { + if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { + killErr = err + } else { + // Give it ~1s to exit gracefully, then SIGKILL if still alive. + time.Sleep(1 * time.Second) + if alive := syscall.Kill(pid, 0); alive == nil { + if err := syscall.Kill(pid, syscall.SIGKILL); err != nil { + killErr = err + } + } + } + } + + // 2. Remove worktree (force). + if worktreePath != "" { + out, err := exec.Command("git", "-C", repoRoot, "worktree", "remove", "--force", worktreePath).CombinedOutput() + if err != nil { + wtErr = fmt.Errorf("worktree remove: %v: %s", err, strings.TrimSpace(string(out))) + } + } + + // 3. Delete the branch. + if branch != "" { + out, err := exec.Command("git", "-C", repoRoot, "branch", "-D", branch).CombinedOutput() + if err != nil { + brErr = fmt.Errorf("branch -D: %v: %s", err, strings.TrimSpace(string(out))) + } + } + + // Only error out if every requested step failed. Individual failures are + // expected (e.g. cleanup called twice, dangling branch already gone). + if killErr != nil && wtErr != nil && brErr != nil { + return fmt.Errorf("cleanup failed: kill=%v; worktree=%v; branch=%v", killErr, wtErr, brErr) + } + return nil +} diff --git a/functions/infra/agent_cleanup_worktree.md b/functions/infra/agent_cleanup_worktree.md new file mode 100644 index 00000000..8c614475 --- /dev/null +++ b/functions/infra/agent_cleanup_worktree.md @@ -0,0 +1,59 @@ +--- +name: agent_cleanup_worktree +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func AgentCleanupWorktree(repoRoot, branch, worktreePath string, pid int) error" +description: "Tear-down de un worktree creado por agent_launch_worktree_go_infra: manda SIGTERM al PID (espera 1s, luego SIGKILL si sigue vivo), corre `git worktree remove --force` y `git branch -D` (best-effort cada uno). Devuelve error SOLO si los tres pasos fallan — fallos individuales son esperados (cleanup doble, rama ya borrada, etc.). PID=0 desactiva el kill (util cuando el proceso ya murio o nunca arranco). Linux/Darwin: usa syscall.Kill. Windows: la funcion compila pero el kill nunca hace nada porque syscall.Kill no existe alli — documentar como skip." +tags: [agents, worktree, cleanup, git, kill] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["fmt", "os/exec", "strings", "syscall", "time"] +params: + - name: repoRoot + desc: "path absoluto al repo principal (el que tiene el worktree registrado)." + - name: branch + desc: "nombre de la rama a borrar (ej. auto/0115-foo). Vacio = skip." + - name: worktreePath + desc: "path absoluto al worktree a eliminar. Vacio = skip." + - name: pid + desc: "PID de claude o 0 para saltarse el kill (proceso ya muerto / nunca arranco)." +output: "error nil cuando al menos uno de los tres pasos (kill, worktree remove, branch -D) tuvo exito o se salto. error no-nil solo si los tres fallaron — incluye los tres mensajes para diagnostico." +tested: true +tests: + - "removes worktree dir and branch after launch" + - "tolerates missing worktree/branch (cleanup called twice)" +test_file_path: "functions/infra/agent_cleanup_worktree_test.go" +file_path: "functions/infra/agent_cleanup_worktree.go" +--- + +## Ejemplo + +```go +err := infra.AgentCleanupWorktree( + "/home/lucas/fn_registry", + "auto/0115-worktree-launcher-fn", + "/home/lucas/fn_registry/worktrees/0115-worktree-launcher-fn", + 12345, // PID devuelto por AgentLaunchWorktree +) +if err != nil { + log.Printf("cleanup partial failure: %v", err) +} +``` + +## Cuando usarla + +Tras terminar (o abortar) un run lanzado con `agent_launch_worktree_go_infra`. Tambien util en defers de tests para garantizar limpieza: `defer infra.AgentCleanupWorktree(repo, branch, wt, res.PID)`. Si el run sigue corriendo y solo quieres parar el proceso sin tocar git, llama tu mismo a `syscall.Kill(pid, syscall.SIGTERM)` — esta funcion hace mas que eso. + +## Gotchas + +- **Best-effort por diseño**: cleanup doble no es error. Es deliberado para que `agent_runner_api` pueda llamarla en abort handlers sin meter el sistema en bucle. +- **SIGTERM grace 1s**: si claude tarda mas de 1s en cerrar limpiamente, se mata con SIGKILL — los buffers del log pueden quedar parcialmente escritos. Si necesitas mas grace, fork la funcion. +- **Windows**: `syscall.Kill` no existe en Windows. El codigo compila pero salta el kill silenciosamente. Para Windows real, swap `syscall.Kill` por `os.Process.Kill()` (requiere abrir el proceso primero con `os.FindProcess`). +- **Branch en HEAD del repo principal**: si la rama a borrar es la checked-out branch del repo principal, `git branch -D` falla — pero como worktree elimino ya su HEAD, en la practica nunca pasa con ramas `auto/*`. +- **Worktree con cambios sin commitear**: `--force` los descarta. Si necesitas preservar trabajo, commitea y push antes de llamar. diff --git a/functions/infra/agent_cleanup_worktree_test.go b/functions/infra/agent_cleanup_worktree_test.go new file mode 100644 index 00000000..65cd42c7 --- /dev/null +++ b/functions/infra/agent_cleanup_worktree_test.go @@ -0,0 +1,57 @@ +package infra + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestAgentCleanupWorktree_RemovesWorktreeAndBranch(t *testing.T) { + repo := initDummyRepo(t) + wt := filepath.Join(t.TempDir(), "wt-cleanup") + log := filepath.Join(t.TempDir(), "claude.log") + + res := AgentLaunchWorktree(WorktreeLaunchConfig{ + RepoRoot: repo, + Branch: "auto/cleanup-test", + WorktreePath: wt, + Prompt: "x", + LogPath: log, + }) + if res.Error != "" { + t.Fatalf("launch failed: %s", res.Error) + } + + if err := AgentCleanupWorktree(repo, "auto/cleanup-test", wt, res.PID); err != nil { + t.Fatalf("cleanup returned error: %v", err) + } + + // Worktree dir should be gone. + if _, err := os.Stat(wt); !os.IsNotExist(err) { + t.Fatalf("worktree dir should be removed, stat err=%v", err) + } + // Branch should be gone. + out, _ := exec.Command("git", "-C", repo, "branch", "--list", "auto/cleanup-test").CombinedOutput() + if strings.Contains(string(out), "auto/cleanup-test") { + t.Fatalf("branch should be deleted, got: %s", out) + } +} + +func TestAgentCleanupWorktree_TolerantToMissing(t *testing.T) { + repo := initDummyRepo(t) + // Worktree path that never existed; branch that never existed; PID 0. + err := AgentCleanupWorktree(repo, "no-such-branch", filepath.Join(t.TempDir(), "ghost-wt"), 0) + if err == nil { + // All three steps failed individually but PID=0 skipped the kill, so + // killErr==nil and the combined check returns nil. That's the + // expected "tolerant" behaviour. + return + } + // If some git versions surface a different error path, just ensure we + // didn't panic and the error message is informative. + if !strings.Contains(err.Error(), "cleanup failed") { + t.Fatalf("unexpected error shape: %v", err) + } +} diff --git a/functions/infra/agent_launch_worktree.go b/functions/infra/agent_launch_worktree.go new file mode 100644 index 00000000..a4da464e --- /dev/null +++ b/functions/infra/agent_launch_worktree.go @@ -0,0 +1,121 @@ +package infra + +import ( + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +// WorktreeLaunchConfig configures a headless Claude agent run inside a fresh +// git worktree. All paths must be absolute. The function spawns claude in the +// background and returns immediately with the PID; the caller is responsible +// for AgentCleanupWorktree when the run finishes (or aborts). +type WorktreeLaunchConfig struct { + RepoRoot string // absolute path to the main repo (git -C ) + Branch string // e.g. "auto/0115-foo" — created from master + WorktreePath string // absolute path where worktree gets added + Prompt string // text passed to claude -p + LogPath string // file claude stdout/stderr is redirected to + Env map[string]string // extra env vars merged on top of os.Environ() + SkipPerms bool // adds --dangerously-skip-permissions + ResetIfExists bool // if true, branch + worktree are nuked first +} + +// WorktreeLaunchResult is the return shape of AgentLaunchWorktree. +type WorktreeLaunchResult struct { + PID int // claude process id (0 if Error != "") + Branch string // echoes cfg.Branch + WorktreePath string // echoes cfg.WorktreePath + LogPath string // echoes cfg.LogPath + StartedAt int64 // unix seconds when cmd.Start() returned + Error string // empty on success; populated on any failure +} + +// AgentLaunchWorktree creates a fresh git worktree on a new branch off master +// and spawns `claude -p ` headless inside that worktree, redirecting +// stdout+stderr to LogPath. Returns immediately (process keeps running). +// +// If `claude` is not in PATH, falls back to an `echo` stub so tests can run +// without the real binary — the stub still produces a real PID and log file. +// +// Impure: touches the filesystem (worktree), spawns a process, writes a log. +func AgentLaunchWorktree(cfg WorktreeLaunchConfig) WorktreeLaunchResult { + res := WorktreeLaunchResult{ + Branch: cfg.Branch, + WorktreePath: cfg.WorktreePath, + LogPath: cfg.LogPath, + } + + if cfg.RepoRoot == "" || cfg.Branch == "" || cfg.WorktreePath == "" { + res.Error = "RepoRoot, Branch and WorktreePath are required" + return res + } + + // Best-effort cleanup of pre-existing branch/worktree. + if cfg.ResetIfExists { + _ = exec.Command("git", "-C", cfg.RepoRoot, "worktree", "remove", "--force", cfg.WorktreePath).Run() + _ = exec.Command("git", "-C", cfg.RepoRoot, "branch", "-D", cfg.Branch).Run() + // Best-effort dir cleanup (git worktree remove leaves nothing, but + // just in case the dir was created out-of-band). + _ = os.RemoveAll(cfg.WorktreePath) + } + + // Create the new worktree off master. + addCmd := exec.Command("git", "-C", cfg.RepoRoot, "worktree", "add", cfg.WorktreePath, "-b", cfg.Branch, "master") + if out, err := addCmd.CombinedOutput(); err != nil { + res.Error = fmt.Sprintf("git worktree add failed: %v: %s", err, strings.TrimSpace(string(out))) + return res + } + + // Open / truncate log file. + logFile, err := os.OpenFile(cfg.LogPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + if err != nil { + res.Error = fmt.Sprintf("open log %s: %v", cfg.LogPath, err) + return res + } + + // Resolve claude binary; fall back to echo stub if not found. + claudeBin, lookErr := exec.LookPath("claude") + var args []string + var bin string + if lookErr != nil { + bin = "echo" + args = []string{"STUB: claude not in PATH, prompt was:", cfg.Prompt} + } else { + bin = claudeBin + if cfg.SkipPerms { + args = append(args, "--dangerously-skip-permissions") + } + args = append(args, "-p", cfg.Prompt) + } + + cmd := exec.Command(bin, args...) + cmd.Dir = cfg.WorktreePath + cmd.Stdout = logFile + cmd.Stderr = logFile + // Merge env: os.Environ() base + cfg.Env overrides. + env := os.Environ() + for k, v := range cfg.Env { + env = append(env, k+"="+v) + } + cmd.Env = env + + if err := cmd.Start(); err != nil { + _ = logFile.Close() + res.Error = fmt.Sprintf("cmd.Start: %v", err) + return res + } + + // Release the file handle in this process — child still holds it. + // (Closing immediately is OK; the kernel keeps the fd open in the child.) + go func() { + _ = cmd.Wait() + _ = logFile.Close() + }() + + res.PID = cmd.Process.Pid + res.StartedAt = time.Now().Unix() + return res +} diff --git a/functions/infra/agent_launch_worktree.md b/functions/infra/agent_launch_worktree.md new file mode 100644 index 00000000..751dc48a --- /dev/null +++ b/functions/infra/agent_launch_worktree.md @@ -0,0 +1,62 @@ +--- +name: agent_launch_worktree +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func AgentLaunchWorktree(cfg WorktreeLaunchConfig) WorktreeLaunchResult" +description: "Crea un git worktree nuevo en una rama derivada de master y lanza `claude -p ` headless dentro de ese worktree, redirigiendo stdout+stderr a un log file. Devuelve inmediatamente con el PID — el proceso queda corriendo en background. Si `ResetIfExists=true` y la rama existe, borra rama + worktree previos (best-effort) antes de recrear. Si `claude` no esta en PATH, hace fallback a `echo` como stub para que los tests puedan correr sin el binario real. Usa exec.LookPath, NO hardcodea paths. Cleanup del worktree + branch se hace con `agent_cleanup_worktree_go_infra`." +tags: [agents, worktree, claude, git, headless] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["fmt", "os", "os/exec", "strings", "time"] +params: + - name: cfg + desc: "WorktreeLaunchConfig con RepoRoot (path absoluto al repo principal), Branch (ej. auto/0115-foo), WorktreePath (path absoluto donde crear el worktree), Prompt (texto pasado a claude -p), LogPath (archivo de log), Env opcional (env vars extra), SkipPerms (pasa --dangerously-skip-permissions), ResetIfExists (nuke previo de rama+worktree)." +output: "WorktreeLaunchResult con PID (claude process id), Branch/WorktreePath/LogPath (eco de inputs), StartedAt (unix seconds) y Error (string vacio en exito; mensaje en fallo). PID=0 cuando Error!='' . El campo Error usa string en vez de error nativo Go para poder serializarse a JSON desde agent_runner_api." +tested: true +tests: + - "creates worktree dir and branch off master" + - "ResetIfExists=true on existing branch+worktree succeeds" + - "returns Error when RepoRoot/Branch/WorktreePath missing" +test_file_path: "functions/infra/agent_launch_worktree_test.go" +file_path: "functions/infra/agent_launch_worktree.go" +--- + +## Ejemplo + +```go +res := infra.AgentLaunchWorktree(infra.WorktreeLaunchConfig{ + RepoRoot: "/home/lucas/fn_registry", + Branch: "auto/0115-worktree-launcher-fn", + WorktreePath: "/home/lucas/fn_registry/worktrees/0115-worktree-launcher-fn", + Prompt: "Implement issue 0115 — worktree launcher Go function", + LogPath: "/tmp/claude-0115.log", + SkipPerms: true, + ResetIfExists: true, +}) +if res.Error != "" { + log.Fatal(res.Error) +} +fmt.Printf("claude PID=%d branch=%s log=%s\n", res.PID, res.Branch, res.LogPath) +// ... agente trabaja ... +infra.AgentCleanupWorktree(res.WorktreePath, res.Branch, "/home/lucas/fn_registry", res.PID) +``` + +## Cuando usarla + +Cuando una app (`agent_runner_api`, `fn-orquestador`) o un script necesite lanzar Claude headless en un sandbox aislado: ramas `auto/` o `issue/`. Reemplaza el bash inline que vivia en `.claude/skills/parallel-fix-issues/` y en el agente `fn-orquestador`. Si lo que quieres es ejecutar Claude en foreground sin worktree, NO uses esta — usa un `exec.Command` directo. + +## Gotchas + +- **Spawn solo, no Wait**: la funcion hace `cmd.Start()` y vuelve. Si el caller necesita esperar al final, debe trackear el PID y hacer `syscall.Wait4` o consultar `/proc/`. Para cleanup ordenado, usa `agent_cleanup_worktree_go_infra`. +- **Master debe existir** en `RepoRoot` — la rama se crea con `git worktree add ... -b master`. Si tu repo usa `main`, fork la funcion o renombra la rama localmente. +- **`ResetIfExists` es best-effort**: si el worktree previo tiene cambios sin commitear o procesos atados, `git worktree remove --force` puede ignorar ciertos errores; siempre revisa el dir final. +- **Log file truncado**: cada launch reabre `LogPath` con `O_TRUNC`. Si quieres preservar el log de runs anteriores, rota el archivo antes de llamar. +- **Fallback `echo` stub** se activa cuando `exec.LookPath("claude")` falla; en ese caso el "proceso claude" imprime `STUB: claude not in PATH, prompt was: ` y termina inmediatamente. Util en CI/tests, no en produccion. +- **PID en Windows**: `syscall.Kill` no existe en Windows — `agent_cleanup_worktree` solo funciona en Linux/Darwin. Documentado alli. +- **Env**: los valores de `cfg.Env` se hacen append a `os.Environ()` — si quieres anular una var existente, en Go la ultima asignacion gana, asi que basta con incluirla en `cfg.Env`. diff --git a/functions/infra/agent_launch_worktree_test.go b/functions/infra/agent_launch_worktree_test.go new file mode 100644 index 00000000..4bd17a98 --- /dev/null +++ b/functions/infra/agent_launch_worktree_test.go @@ -0,0 +1,118 @@ +package infra + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// initDummyRepo creates a tiny git repo with a master branch and one commit so +// that `git worktree add ... master` can succeed. +func initDummyRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + runIn := func(name string, args ...string) { + cmd := exec.Command(name, args...) + cmd.Dir = dir + // Detach from any global config that might inject signing/templates. + cmd.Env = append(os.Environ(), + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=t@t", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=t@t", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s %v: %v\n%s", name, args, err, out) + } + } + + runIn("git", "init", "-b", "master") + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + runIn("git", "add", "README.md") + runIn("git", "commit", "-m", "init") + + return dir +} + +func TestAgentLaunchWorktree_CreatesWorktreeAndBranch(t *testing.T) { + repo := initDummyRepo(t) + wt := filepath.Join(t.TempDir(), "wt-0001") + log := filepath.Join(t.TempDir(), "claude.log") + + res := AgentLaunchWorktree(WorktreeLaunchConfig{ + RepoRoot: repo, + Branch: "auto/test-0001", + WorktreePath: wt, + Prompt: "hello", + LogPath: log, + }) + + if res.Error != "" { + t.Fatalf("unexpected error: %s", res.Error) + } + if res.PID <= 0 { + t.Fatalf("expected PID>0, got %d", res.PID) + } + if _, err := os.Stat(wt); err != nil { + t.Fatalf("worktree dir missing: %v", err) + } + // Branch should exist in the main repo's refs. + out, err := exec.Command("git", "-C", repo, "branch", "--list", "auto/test-0001").CombinedOutput() + if err != nil || !strings.Contains(string(out), "auto/test-0001") { + t.Fatalf("branch not created: err=%v out=%s", err, out) + } + if _, err := os.Stat(log); err != nil { + t.Fatalf("log file missing: %v", err) + } + + // Cleanup (best-effort, not asserted here). + _ = AgentCleanupWorktree(repo, "auto/test-0001", wt, res.PID) +} + +func TestAgentLaunchWorktree_ResetIfExists(t *testing.T) { + repo := initDummyRepo(t) + wt := filepath.Join(t.TempDir(), "wt-reset") + log := filepath.Join(t.TempDir(), "claude.log") + + // First launch. + res1 := AgentLaunchWorktree(WorktreeLaunchConfig{ + RepoRoot: repo, + Branch: "auto/reset-test", + WorktreePath: wt, + Prompt: "first", + LogPath: log, + }) + if res1.Error != "" { + t.Fatalf("first launch failed: %s", res1.Error) + } + + // Second launch with ResetIfExists=true should succeed and recreate. + res2 := AgentLaunchWorktree(WorktreeLaunchConfig{ + RepoRoot: repo, + Branch: "auto/reset-test", + WorktreePath: wt, + Prompt: "second", + LogPath: log, + ResetIfExists: true, + }) + if res2.Error != "" { + t.Fatalf("reset launch failed: %s", res2.Error) + } + if _, err := os.Stat(wt); err != nil { + t.Fatalf("worktree dir missing after reset: %v", err) + } + + _ = AgentCleanupWorktree(repo, "auto/reset-test", wt, res2.PID) +} + +func TestAgentLaunchWorktree_MissingArgs(t *testing.T) { + res := AgentLaunchWorktree(WorktreeLaunchConfig{}) + if res.Error == "" { + t.Fatal("expected error for empty config, got none") + } +} diff --git a/functions/infra/audit_dod_schema.md b/functions/infra/audit_dod_schema.md index 3f23c9a2..e29f4a32 100644 --- a/functions/infra/audit_dod_schema.md +++ b/functions/infra/audit_dod_schema.md @@ -7,7 +7,7 @@ version: "1.0.0" purity: impure signature: "func AuditDodSchema(issuesDir, flowsDir string) (DodSchemaReport, error)" description: "Escanea dev/issues/ y dev/flows/ (incluidos subdirectorios completed/) y para cada .md parsea el bloque dod_evidence_schema del frontmatter YAML. Valida que cada item tenga id unico, kind in {screenshot,log,url,cmd}, expected no vacio y required bool (default true). Read-only: no modifica nada. Devuelve un DodSchemaReport con files (uno por archivo con items o errores), totales y conteo de items invalidos. Tolerante a frontmatter ausente o malformed — registra el error en el archivo afectado y continua." -tags: [doctor, dod, evidence, frontmatter, taxonomy, validator] +tags: [agents, doctor, dod, evidence, frontmatter, taxonomy, validator] uses_functions: [] uses_types: [] returns: []