feat(dispatcher): Phase 2.2 — Task Dispatcher avec fsnotify
- internal/dispatcher: fsnotify sur inbox/, fallback poll 60s, launchAgent
- parseFrontmatter YAML, modelForPriority (critical→opus, reste→sonnet)
- waitForPrompt polling ❯, buildTaskMessage, 1 tache par session
- isSessionFree: check tmux liveness + state idle + cooldown 5min
- 5 tests unitaires (parse, model, dispatch, no-session, missing-tmux)
- go.mod: ajout fsnotify v1.9.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 20:30:08 +00:00
|
|
|
|
package dispatcher
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"log"
|
|
|
|
|
|
"os"
|
|
|
|
|
|
"path/filepath"
|
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
|
|
"forge.secuaas.ovh/olivier/claude-failover/internal/config"
|
|
|
|
|
|
"forge.secuaas.ovh/olivier/claude-failover/internal/state"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// mockTmux is a minimal in-memory tmux.Client for tests.
|
|
|
|
|
|
type mockTmux struct {
|
|
|
|
|
|
sessions map[string]bool
|
|
|
|
|
|
paneOutput map[string]string
|
|
|
|
|
|
sentKeys []string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func newMockTmux() *mockTmux {
|
|
|
|
|
|
return &mockTmux{
|
|
|
|
|
|
sessions: make(map[string]bool),
|
|
|
|
|
|
paneOutput: make(map[string]string),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (m *mockTmux) HasSession(name string) bool { return m.sessions[name] }
|
|
|
|
|
|
func (m *mockTmux) CreateSession(name, _ string) error { m.sessions[name] = true; return nil }
|
|
|
|
|
|
func (m *mockTmux) KillSession(_ string) error { return nil }
|
|
|
|
|
|
func (m *mockTmux) SendKeys(_, keys string) error {
|
|
|
|
|
|
m.sentKeys = append(m.sentKeys, keys)
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2026-04-15 20:49:59 +00:00
|
|
|
|
func (m *mockTmux) SendEnter(_ string) error {
|
|
|
|
|
|
m.sentKeys = append(m.sentKeys, "<ENTER>")
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
feat(dispatcher): Phase 2.2 — Task Dispatcher avec fsnotify
- internal/dispatcher: fsnotify sur inbox/, fallback poll 60s, launchAgent
- parseFrontmatter YAML, modelForPriority (critical→opus, reste→sonnet)
- waitForPrompt polling ❯, buildTaskMessage, 1 tache par session
- isSessionFree: check tmux liveness + state idle + cooldown 5min
- 5 tests unitaires (parse, model, dispatch, no-session, missing-tmux)
- go.mod: ajout fsnotify v1.9.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 20:30:08 +00:00
|
|
|
|
func (m *mockTmux) CapturePaneTail(session string, _ int) (string, error) {
|
|
|
|
|
|
return m.paneOutput[session], nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestParseFrontmatter verifies YAML frontmatter extraction.
|
|
|
|
|
|
func TestParseFrontmatter(t *testing.T) {
|
|
|
|
|
|
input := "---\ntitle: Fix bug\npriority: critical\n---\nDo the fix."
|
|
|
|
|
|
fm, body := parseFrontmatter([]byte(input))
|
|
|
|
|
|
if fm.Title != "Fix bug" {
|
|
|
|
|
|
t.Errorf("expected title 'Fix bug', got %q", fm.Title)
|
|
|
|
|
|
}
|
|
|
|
|
|
if fm.Priority != "critical" {
|
|
|
|
|
|
t.Errorf("expected priority critical, got %q", fm.Priority)
|
|
|
|
|
|
}
|
|
|
|
|
|
if body != "Do the fix." {
|
|
|
|
|
|
t.Errorf("expected body 'Do the fix.', got %q", body)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestParseFrontmatterNoHeader handles files without a YAML header.
|
|
|
|
|
|
func TestParseFrontmatterNoHeader(t *testing.T) {
|
|
|
|
|
|
input := "Just plain content."
|
|
|
|
|
|
fm, body := parseFrontmatter([]byte(input))
|
|
|
|
|
|
if fm.Title != "" {
|
|
|
|
|
|
t.Errorf("expected empty title, got %q", fm.Title)
|
|
|
|
|
|
}
|
|
|
|
|
|
if body != "Just plain content." {
|
|
|
|
|
|
t.Errorf("expected full body, got %q", body)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestModelForPriority maps priority strings to model names.
|
|
|
|
|
|
func TestModelForPriority(t *testing.T) {
|
|
|
|
|
|
cases := []struct{ priority, want string }{
|
|
|
|
|
|
{"critical", "opus"},
|
|
|
|
|
|
{"CRITICAL", "opus"},
|
|
|
|
|
|
{"high", "sonnet"},
|
|
|
|
|
|
{"default", "sonnet"},
|
|
|
|
|
|
{"", "sonnet"},
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, c := range cases {
|
|
|
|
|
|
if got := modelForPriority(c.priority); got != c.want {
|
|
|
|
|
|
t.Errorf("modelForPriority(%q) = %q, want %q", c.priority, got, c.want)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestFindFreeSessionSkipsFailed verifies that recently-failed sessions are skipped.
|
|
|
|
|
|
func TestFindFreeSessionSkipsFailed(t *testing.T) {
|
|
|
|
|
|
tc := newMockTmux()
|
2026-04-16 13:30:26 +00:00
|
|
|
|
tc.sessions["sess-0"] = true
|
feat(dispatcher): Phase 2.2 — Task Dispatcher avec fsnotify
- internal/dispatcher: fsnotify sur inbox/, fallback poll 60s, launchAgent
- parseFrontmatter YAML, modelForPriority (critical→opus, reste→sonnet)
- waitForPrompt polling ❯, buildTaskMessage, 1 tache par session
- isSessionFree: check tmux liveness + state idle + cooldown 5min
- 5 tests unitaires (parse, model, dispatch, no-session, missing-tmux)
- go.mod: ajout fsnotify v1.9.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 20:30:08 +00:00
|
|
|
|
tc.sessions["sess-1"] = true
|
|
|
|
|
|
|
|
|
|
|
|
s := state.New("")
|
2026-04-16 13:30:26 +00:00
|
|
|
|
s.SetFailed("sess-0")
|
feat(dispatcher): Phase 2.2 — Task Dispatcher avec fsnotify
- internal/dispatcher: fsnotify sur inbox/, fallback poll 60s, launchAgent
- parseFrontmatter YAML, modelForPriority (critical→opus, reste→sonnet)
- waitForPrompt polling ❯, buildTaskMessage, 1 tache par session
- isSessionFree: check tmux liveness + state idle + cooldown 5min
- 5 tests unitaires (parse, model, dispatch, no-session, missing-tmux)
- go.mod: ajout fsnotify v1.9.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 20:30:08 +00:00
|
|
|
|
s.SetIdle("sess-1")
|
|
|
|
|
|
|
|
|
|
|
|
d := &Dispatcher{
|
|
|
|
|
|
tmux: tc,
|
|
|
|
|
|
state: s,
|
|
|
|
|
|
config: &config.Config{
|
|
|
|
|
|
Pool: config.PoolConfig{
|
2026-04-16 13:30:26 +00:00
|
|
|
|
Autonomous: config.AutonomousConfig{Prefix: "sess-", Max: 2},
|
feat(dispatcher): Phase 2.2 — Task Dispatcher avec fsnotify
- internal/dispatcher: fsnotify sur inbox/, fallback poll 60s, launchAgent
- parseFrontmatter YAML, modelForPriority (critical→opus, reste→sonnet)
- waitForPrompt polling ❯, buildTaskMessage, 1 tache par session
- isSessionFree: check tmux liveness + state idle + cooldown 5min
- 5 tests unitaires (parse, model, dispatch, no-session, missing-tmux)
- go.mod: ajout fsnotify v1.9.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 20:30:08 +00:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
logger: log.Default(),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
got := d.findFreeSession()
|
|
|
|
|
|
if got != "sess-1" {
|
|
|
|
|
|
t.Errorf("expected sess-1, got %q", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestFindFreeSessionMissingTmux skips sessions not in tmux.
|
|
|
|
|
|
func TestFindFreeSessionMissingTmux(t *testing.T) {
|
|
|
|
|
|
tc := newMockTmux()
|
2026-04-16 13:30:26 +00:00
|
|
|
|
// sess-0 missing from tmux, sess-1 present and idle.
|
|
|
|
|
|
tc.sessions["sess-1"] = true
|
feat(dispatcher): Phase 2.2 — Task Dispatcher avec fsnotify
- internal/dispatcher: fsnotify sur inbox/, fallback poll 60s, launchAgent
- parseFrontmatter YAML, modelForPriority (critical→opus, reste→sonnet)
- waitForPrompt polling ❯, buildTaskMessage, 1 tache par session
- isSessionFree: check tmux liveness + state idle + cooldown 5min
- 5 tests unitaires (parse, model, dispatch, no-session, missing-tmux)
- go.mod: ajout fsnotify v1.9.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 20:30:08 +00:00
|
|
|
|
|
|
|
|
|
|
s := state.New("")
|
2026-04-16 13:30:26 +00:00
|
|
|
|
s.SetIdle("sess-0")
|
feat(dispatcher): Phase 2.2 — Task Dispatcher avec fsnotify
- internal/dispatcher: fsnotify sur inbox/, fallback poll 60s, launchAgent
- parseFrontmatter YAML, modelForPriority (critical→opus, reste→sonnet)
- waitForPrompt polling ❯, buildTaskMessage, 1 tache par session
- isSessionFree: check tmux liveness + state idle + cooldown 5min
- 5 tests unitaires (parse, model, dispatch, no-session, missing-tmux)
- go.mod: ajout fsnotify v1.9.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 20:30:08 +00:00
|
|
|
|
s.SetIdle("sess-1")
|
|
|
|
|
|
|
|
|
|
|
|
d := &Dispatcher{
|
|
|
|
|
|
tmux: tc,
|
|
|
|
|
|
state: s,
|
|
|
|
|
|
config: &config.Config{
|
|
|
|
|
|
Pool: config.PoolConfig{
|
2026-04-16 13:30:26 +00:00
|
|
|
|
Autonomous: config.AutonomousConfig{Prefix: "sess-", Max: 2},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
logger: log.Default(),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
got := d.findFreeSession()
|
|
|
|
|
|
if got != "sess-1" {
|
|
|
|
|
|
t.Errorf("expected sess-1, got %q", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestFindFreeSessionSkipsDedicated verifies that dedicated sessions are
|
|
|
|
|
|
// NEVER returned by the auto-dispatch path, even when idle. Those host the
|
|
|
|
|
|
// operator's manual interactive work and must stay untouched.
|
|
|
|
|
|
func TestFindFreeSessionSkipsDedicated(t *testing.T) {
|
|
|
|
|
|
tc := newMockTmux()
|
|
|
|
|
|
tc.sessions["ccl-1-conformvault"] = true
|
|
|
|
|
|
tc.sessions["sess-0"] = true
|
|
|
|
|
|
|
|
|
|
|
|
s := state.New("")
|
|
|
|
|
|
s.SetIdle("ccl-1-conformvault")
|
|
|
|
|
|
s.SetIdle("sess-0")
|
|
|
|
|
|
|
|
|
|
|
|
d := &Dispatcher{
|
|
|
|
|
|
tmux: tc,
|
|
|
|
|
|
state: s,
|
|
|
|
|
|
config: &config.Config{
|
|
|
|
|
|
Pool: config.PoolConfig{
|
|
|
|
|
|
Dedicated: []config.DedicatedSession{{Name: "ccl-1-conformvault"}},
|
|
|
|
|
|
Autonomous: config.AutonomousConfig{Prefix: "sess-", Max: 1},
|
feat(dispatcher): Phase 2.2 — Task Dispatcher avec fsnotify
- internal/dispatcher: fsnotify sur inbox/, fallback poll 60s, launchAgent
- parseFrontmatter YAML, modelForPriority (critical→opus, reste→sonnet)
- waitForPrompt polling ❯, buildTaskMessage, 1 tache par session
- isSessionFree: check tmux liveness + state idle + cooldown 5min
- 5 tests unitaires (parse, model, dispatch, no-session, missing-tmux)
- go.mod: ajout fsnotify v1.9.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 20:30:08 +00:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
logger: log.Default(),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
got := d.findFreeSession()
|
2026-04-16 13:30:26 +00:00
|
|
|
|
if got != "sess-0" {
|
|
|
|
|
|
t.Errorf("expected pool sess-0 (dedicated must be skipped), got %q", got)
|
feat(dispatcher): Phase 2.2 — Task Dispatcher avec fsnotify
- internal/dispatcher: fsnotify sur inbox/, fallback poll 60s, launchAgent
- parseFrontmatter YAML, modelForPriority (critical→opus, reste→sonnet)
- waitForPrompt polling ❯, buildTaskMessage, 1 tache par session
- isSessionFree: check tmux liveness + state idle + cooldown 5min
- 5 tests unitaires (parse, model, dispatch, no-session, missing-tmux)
- go.mod: ajout fsnotify v1.9.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 20:30:08 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestDispatchProject creates a task file, dispatches it, and checks state + rename.
|
|
|
|
|
|
func TestDispatchProject(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
inbox := filepath.Join(dir, ".agent-queue", "inbox")
|
|
|
|
|
|
os.MkdirAll(inbox, 0755)
|
|
|
|
|
|
|
|
|
|
|
|
taskContent := "---\ntitle: My Task\npriority: high\n---\nDo the work."
|
|
|
|
|
|
taskPath := filepath.Join(inbox, "task-001.md")
|
|
|
|
|
|
os.WriteFile(taskPath, []byte(taskContent), 0644)
|
|
|
|
|
|
|
|
|
|
|
|
tc := newMockTmux()
|
2026-04-16 13:30:26 +00:00
|
|
|
|
tc.sessions["pool-0"] = true
|
feat(dispatcher): Phase 2.2 — Task Dispatcher avec fsnotify
- internal/dispatcher: fsnotify sur inbox/, fallback poll 60s, launchAgent
- parseFrontmatter YAML, modelForPriority (critical→opus, reste→sonnet)
- waitForPrompt polling ❯, buildTaskMessage, 1 tache par session
- isSessionFree: check tmux liveness + state idle + cooldown 5min
- 5 tests unitaires (parse, model, dispatch, no-session, missing-tmux)
- go.mod: ajout fsnotify v1.9.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 20:30:08 +00:00
|
|
|
|
// Return ❯ prompt on first CapturePaneTail call (Claude is ready).
|
2026-04-16 13:30:26 +00:00
|
|
|
|
tc.paneOutput["pool-0"] = "❯ "
|
feat(dispatcher): Phase 2.2 — Task Dispatcher avec fsnotify
- internal/dispatcher: fsnotify sur inbox/, fallback poll 60s, launchAgent
- parseFrontmatter YAML, modelForPriority (critical→opus, reste→sonnet)
- waitForPrompt polling ❯, buildTaskMessage, 1 tache par session
- isSessionFree: check tmux liveness + state idle + cooldown 5min
- 5 tests unitaires (parse, model, dispatch, no-session, missing-tmux)
- go.mod: ajout fsnotify v1.9.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 20:30:08 +00:00
|
|
|
|
|
|
|
|
|
|
s := state.New("")
|
2026-04-16 13:30:26 +00:00
|
|
|
|
s.SetIdle("pool-0")
|
feat(dispatcher): Phase 2.2 — Task Dispatcher avec fsnotify
- internal/dispatcher: fsnotify sur inbox/, fallback poll 60s, launchAgent
- parseFrontmatter YAML, modelForPriority (critical→opus, reste→sonnet)
- waitForPrompt polling ❯, buildTaskMessage, 1 tache par session
- isSessionFree: check tmux liveness + state idle + cooldown 5min
- 5 tests unitaires (parse, model, dispatch, no-session, missing-tmux)
- go.mod: ajout fsnotify v1.9.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 20:30:08 +00:00
|
|
|
|
|
|
|
|
|
|
d := &Dispatcher{
|
|
|
|
|
|
tmux: tc,
|
|
|
|
|
|
state: s,
|
|
|
|
|
|
config: &config.Config{
|
|
|
|
|
|
Pool: config.PoolConfig{
|
2026-04-16 13:30:26 +00:00
|
|
|
|
Autonomous: config.AutonomousConfig{Prefix: "pool-", Max: 1},
|
feat(dispatcher): Phase 2.2 — Task Dispatcher avec fsnotify
- internal/dispatcher: fsnotify sur inbox/, fallback poll 60s, launchAgent
- parseFrontmatter YAML, modelForPriority (critical→opus, reste→sonnet)
- waitForPrompt polling ❯, buildTaskMessage, 1 tache par session
- isSessionFree: check tmux liveness + state idle + cooldown 5min
- 5 tests unitaires (parse, model, dispatch, no-session, missing-tmux)
- go.mod: ajout fsnotify v1.9.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 20:30:08 +00:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
logger: log.Default(),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
d.dispatchProject(inbox)
|
|
|
|
|
|
|
2026-04-16 13:30:26 +00:00
|
|
|
|
if st := s.GetSession("pool-0"); st == nil || st.State != "working" {
|
feat(dispatcher): Phase 2.2 — Task Dispatcher avec fsnotify
- internal/dispatcher: fsnotify sur inbox/, fallback poll 60s, launchAgent
- parseFrontmatter YAML, modelForPriority (critical→opus, reste→sonnet)
- waitForPrompt polling ❯, buildTaskMessage, 1 tache par session
- isSessionFree: check tmux liveness + state idle + cooldown 5min
- 5 tests unitaires (parse, model, dispatch, no-session, missing-tmux)
- go.mod: ajout fsnotify v1.9.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 20:30:08 +00:00
|
|
|
|
t.Errorf("expected session working after dispatch, got %v", st)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Original file renamed to .dispatched.
|
|
|
|
|
|
if _, err := os.Stat(taskPath + ".dispatched"); os.IsNotExist(err) {
|
|
|
|
|
|
t.Error("expected .dispatched marker")
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := os.Stat(taskPath); !os.IsNotExist(err) {
|
|
|
|
|
|
t.Error("expected original task file to be renamed")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(safety): PreToolUse hook gating destructive tool calls (FNDG-04b, Option A)
Adds internal/safety/ — the in-repo source of truth for the PreToolUse hook
deployed into every project before a Claude Code agent is launched. The hook
blocks destructive Bash/Edit/Write patterns on sessions running with
--dangerously-skip-permissions, closing the exploitation path where a prompt
injection via MCP sessions.send could otherwise trigger arbitrary destruction
without interactive confirmation.
Wire-up:
- internal/dispatcher/dispatcher.go launchAgent: deploys hook before claude
launch; fail-closed if deployment fails.
- internal/switcher/account_switcher.go relaunchDedicatedSessions: redeploys
hook before --resume after account failover; fail-open (log + continue)
since the initial deployment is still in place.
Blocks (exit 2, stderr shown to model):
- rm -rf targeting /, ~, $HOME, /etc, /var, /usr, /boot
- dd of=/dev/{sd,nvme,disk,hd,mmcblk}*, mkfs*
- git push --force (but allows --force-with-lease)
- git reset --hard on main|master|production
- sudo outside short allowlist (systemctl, journalctl, cp, install, apt*)
- curl|sh, bash <(curl ...), eval "$(curl ...)", fork bomb, crontab -e
- chmod 777 on system paths / home
- Writes to .claude/settings*.json, .claude/hooks/, ~/.ssh/authorized_keys,
shell rc files, /etc/sudoers*, /etc/systemd/*
Warn-only (logged, not blocked):
- kubectl delete, helm uninstall, terraform destroy
- DROP TABLE, TRUNCATE TABLE, DELETE FROM ... WHERE 1=1
Hook script is embedded via //go:embed so a single binary release carries
the authoritative copy. Every launch rewrites the deployed file with mode
0555 (anti-tamper); the hook itself also blocks writes to .claude/hooks/
for defense in depth.
Decision: Olivier, 2026-04-19 — Option A now, Option C (two pools) tracked
separately. Complements FNDG-04 input sanitization in secuaas-mcp.
Tests: 8 unit/integration tests in internal/safety/, plus a dispatcher-level
test verifying the hook is written before launch. go vet clean, go test ./...
all pass.
Refs: FNDG-04 audit (secuaas-mcp branch audit/mcp-stdio-2026-04-18)
Task: .agent-queue/inbox/20260418-211102-fndg-04b-*.md
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 17:48:27 +00:00
|
|
|
|
// TestDispatchProjectDeploysSafetyHook verifies that the PreToolUse safety hook
|
|
|
|
|
|
// is written to the project directory before Claude is launched (FNDG-04b).
|
|
|
|
|
|
func TestDispatchProjectDeploysSafetyHook(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
inbox := filepath.Join(dir, ".agent-queue", "inbox")
|
|
|
|
|
|
os.MkdirAll(inbox, 0755)
|
|
|
|
|
|
|
|
|
|
|
|
taskPath := filepath.Join(inbox, "task-hook.md")
|
|
|
|
|
|
os.WriteFile(taskPath, []byte("---\npriority: high\n---\nDo the work."), 0644)
|
|
|
|
|
|
|
|
|
|
|
|
tc := newMockTmux()
|
|
|
|
|
|
tc.sessions["pool-0"] = true
|
|
|
|
|
|
tc.paneOutput["pool-0"] = "❯ "
|
|
|
|
|
|
|
|
|
|
|
|
s := state.New("")
|
|
|
|
|
|
s.SetIdle("pool-0")
|
|
|
|
|
|
|
|
|
|
|
|
d := &Dispatcher{
|
|
|
|
|
|
tmux: tc,
|
|
|
|
|
|
state: s,
|
|
|
|
|
|
config: &config.Config{
|
|
|
|
|
|
Pool: config.PoolConfig{
|
|
|
|
|
|
Autonomous: config.AutonomousConfig{Prefix: "pool-", Max: 1},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
logger: log.Default(),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
d.dispatchProject(inbox)
|
|
|
|
|
|
|
|
|
|
|
|
hookPath := filepath.Join(dir, ".claude", "hooks", "claude-safety-hook.sh")
|
|
|
|
|
|
if _, err := os.Stat(hookPath); os.IsNotExist(err) {
|
|
|
|
|
|
t.Errorf("safety hook not deployed at %s", hookPath)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
settingsPath := filepath.Join(dir, ".claude", "settings.json")
|
|
|
|
|
|
if _, err := os.Stat(settingsPath); os.IsNotExist(err) {
|
|
|
|
|
|
t.Errorf(".claude/settings.json not created at %s", settingsPath)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(dispatcher): Phase 2.2 — Task Dispatcher avec fsnotify
- internal/dispatcher: fsnotify sur inbox/, fallback poll 60s, launchAgent
- parseFrontmatter YAML, modelForPriority (critical→opus, reste→sonnet)
- waitForPrompt polling ❯, buildTaskMessage, 1 tache par session
- isSessionFree: check tmux liveness + state idle + cooldown 5min
- 5 tests unitaires (parse, model, dispatch, no-session, missing-tmux)
- go.mod: ajout fsnotify v1.9.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 20:30:08 +00:00
|
|
|
|
// TestDispatchProjectNoFreeSession leaves the task untouched when no session is available.
|
|
|
|
|
|
func TestDispatchProjectNoFreeSession(t *testing.T) {
|
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
|
inbox := filepath.Join(dir, ".agent-queue", "inbox")
|
|
|
|
|
|
os.MkdirAll(inbox, 0755)
|
|
|
|
|
|
|
|
|
|
|
|
taskPath := filepath.Join(inbox, "task-002.md")
|
|
|
|
|
|
os.WriteFile(taskPath, []byte("content"), 0644)
|
|
|
|
|
|
|
|
|
|
|
|
tc := newMockTmux() // no sessions
|
|
|
|
|
|
s := state.New("")
|
|
|
|
|
|
|
|
|
|
|
|
d := &Dispatcher{
|
|
|
|
|
|
tmux: tc,
|
|
|
|
|
|
state: s,
|
|
|
|
|
|
config: &config.Config{
|
|
|
|
|
|
Pool: config.PoolConfig{
|
|
|
|
|
|
Autonomous: config.AutonomousConfig{Max: 0},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
logger: log.Default(),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
d.dispatchProject(inbox)
|
|
|
|
|
|
|
|
|
|
|
|
// File must remain unchanged.
|
|
|
|
|
|
if _, err := os.Stat(taskPath); os.IsNotExist(err) {
|
|
|
|
|
|
t.Error("task file should remain when no session is free")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|