diff --git a/api/openapi.yaml b/api/openapi.yaml index 5cb39163..feccfa54 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -448,6 +448,46 @@ paths: schema: type: object + /v1/agent/status: + get: + operationId: agentLiveStatus + tags: [system] + summary: Live per-agent status for external mission-control dashboards + description: | + Returns machine-readable live state (working/idle/stale) for every + active agent session, derived from daemon ground truth: an in-flight + generation means working; recency of last use distinguishes idle from + stale. Designed for supervisors such as terminal mission-control + dashboards to poll. + responses: + "200": + description: Live agent statuses + content: + application/json: + schema: + type: object + properties: + generated_at: + type: string + agents: + type: array + items: + type: object + properties: + session_id: + type: string + agent: + type: string + state: + type: string + enum: [working, idle, stale] + turns: + type: integer + cwd: + type: string + last_used: + type: string + /v1/ready: get: operationId: readinessProbe diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index a10fcfa6..6c890915 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -446,6 +446,7 @@ func (s *Server) ready() (bool, string) { func (s *Server) routes() { s.handle("GET /v1/health", s.handleHealth) s.handle("GET /v1/status", s.auth(s.rate(s.handleStatus, s.apiLimiter))) + s.handle("GET /v1/agent/status", s.auth(s.rate(s.handleAgentStatus, s.apiLimiter))) s.handle("GET /v1/ready", s.handleReady) s.handle("POST /v1/chat", s.auth(s.rate(s.handleChat, s.chatLimiter))) s.handle("POST /v1/cancel", s.auth(s.rate(s.handleCancel, s.apiLimiter))) diff --git a/internal/daemon/routes_agent_status.go b/internal/daemon/routes_agent_status.go new file mode 100644 index 00000000..d76e927d --- /dev/null +++ b/internal/daemon/routes_agent_status.go @@ -0,0 +1,62 @@ +package daemon + +import ( + "net/http" + "time" +) + +// AgentLiveStatus is one agent session's machine-readable live state — the +// contract external mission-control dashboards (Luvus-style supervisors) poll +// to show blocked/working/done/idle without scraping terminal output. +type AgentLiveStatus struct { + SessionID string `json:"session_id"` + Agent string `json:"agent,omitempty"` + State string `json:"state"` // working | idle | stale + Turns int `json:"turns"` + CWD string `json:"cwd,omitempty"` + LastUsed time.Time `json:"last_used"` +} + +// AgentStatusResponse is the JSON response from GET /v1/agent/status. +type AgentStatusResponse struct { + GeneratedAt string `json:"generated_at"` + Agents []AgentLiveStatus `json:"agents"` +} + +// handleAgentStatus reports per-session live agent state derived from daemon +// ground truth: an in-flight generation (registered cancel) means "working"; +// otherwise recency of last use distinguishes idle from stale. +func (s *Server) handleAgentStatus(w http.ResponseWriter, _ *http.Request) { + s.cancelMu.Lock() + inFlight := make(map[string]bool, len(s.cancels)) + for id := range s.cancels { + inFlight[id] = true + } + s.cancelMu.Unlock() + + now := time.Now() + resp := AgentStatusResponse{GeneratedAt: now.UTC().Format(time.RFC3339), Agents: []AgentLiveStatus{}} + s.sessions.Range(func(_, v any) bool { + sess, ok := v.(*Session) + if !ok { + return true + } + st := AgentLiveStatus{ + SessionID: sess.ID, + Agent: sess.Agent, + State: "idle", + Turns: sess.Turns, + CWD: sess.CWD, + LastUsed: sess.LastUsed, + } + switch { + case inFlight[sess.ID]: + st.State = "working" + case now.Sub(sess.LastUsed) > 30*time.Minute: + st.State = "stale" + } + resp.Agents = append(resp.Agents, st) + return true + }) + writeJSON(w, http.StatusOK, resp) +} diff --git a/internal/daemon/routes_agent_status_test.go b/internal/daemon/routes_agent_status_test.go new file mode 100644 index 00000000..e6c1d7a5 --- /dev/null +++ b/internal/daemon/routes_agent_status_test.go @@ -0,0 +1,70 @@ +package daemon + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestAgentStatusStates(t *testing.T) { + s := New(DefaultConfig(), nil) + now := time.Now() + + // working: in-flight cancel registered. + s.sessions.Store("working-1", &Session{ID: "working-1", Agent: "hawk", Turns: 3, LastUsed: now}) + s.cancelMu.Lock() + s.cancels["working-1"] = &cancelEntry{cancel: func() {}} + s.cancelMu.Unlock() + // idle: recent activity, no in-flight generation. + s.sessions.Store("idle-1", &Session{ID: "idle-1", Turns: 1, LastUsed: now.Add(-2 * time.Minute)}) + // stale: no activity for over 30 minutes. + s.sessions.Store("stale-1", &Session{ID: "stale-1", LastUsed: now.Add(-45 * time.Minute)}) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/agent/status", nil) + s.handleAgentStatus(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + var body struct { + GeneratedAt string `json:"generated_at"` + Agents []struct { + SessionID string `json:"session_id"` + State string `json:"state"` + Turns int `json:"turns"` + } `json:"agents"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + states := map[string]string{} + for _, a := range body.Agents { + states[a.SessionID] = a.State + } + if states["working-1"] != "working" { + t.Fatalf("working-1 = %q", states["working-1"]) + } + if states["idle-1"] != "idle" { + t.Fatalf("idle-1 = %q", states["idle-1"]) + } + if states["stale-1"] != "stale" { + t.Fatalf("stale-1 = %q", states["stale-1"]) + } +} + +func TestAgentStatusEmpty(t *testing.T) { + s := New(DefaultConfig(), nil) + rec := httptest.NewRecorder() + s.handleAgentStatus(rec, httptest.NewRequest(http.MethodGet, "/v1/agent/status", nil)) + var body struct { + Agents []struct{} `json:"agents"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if len(body.Agents) != 0 { + t.Fatalf("expected empty agents, got %d", len(body.Agents)) + } +} diff --git a/internal/multiagent/mission.go b/internal/multiagent/mission.go index 4aafdac8..138956e8 100644 --- a/internal/multiagent/mission.go +++ b/internal/multiagent/mission.go @@ -30,6 +30,12 @@ type Mission struct { // When nil the gate is a no-op and all tool calls proceed automatically. ApprovalGate *MissionApprovalGate `json:"-"` + // pathLedger records which files each completed feature touched so + // overlapping changes between parallel branches surface before merge + // (Luvus-style file-path reservation / dependent-task coordination). + // Nil until first use; guarded by mu. + pathLedger *PathReservationLedger + mu sync.Mutex } @@ -288,6 +294,18 @@ func (m *Mission) runFeatureSet(ctx context.Context, workerFn WorkerFunc, missio } else { feat.Status = FeatureCompleted feat.Handoff = handoff + // Record this branch's touched files in the path reservation + // ledger so cross-branch overlaps surface before merge + // (Luvus-style dependent-task coordination). + if len(handoff.FilesChanged) > 0 { + if m.pathLedger == nil { + m.pathLedger = NewPathReservationLedger() + } + // Best-effort: a conflict means another parallel branch in + // this wave already claimed one of these files, which is + // exactly the signal DetectOverlaps reports at merge time. + _ = m.pathLedger.Reserve(feat.ID, feat.ID, handoff.FilesChanged) + } } feat.CompletedAt = time.Now() m.mu.Unlock() @@ -536,6 +554,23 @@ func (m *Mission) Summary() string { m.ID, status, completed, len(m.Features), failed, duration) } +// DetectOverlaps forecasts merge conflicts by comparing the changed-file sets +// of every completed feature's handoff. Deterministic ordering. Call after a +// wave completes (or before merging branches) to sequence dependent tasks or +// flag conflicts early. +func (m *Mission) DetectOverlaps() []Overlap { + m.mu.Lock() + defer m.mu.Unlock() + handoffs := map[string][]string{} + for i := range m.Features { + f := &m.Features[i] + if f.Status == FeatureCompleted && f.Handoff != nil && len(f.Handoff.FilesChanged) > 0 { + handoffs[f.ID] = f.Handoff.FilesChanged + } + } + return DetectFileOverlaps(handoffs) +} + func (m *Mission) createDir() (string, error) { dir := filepath.Join(os.TempDir(), "hawk-missions", m.ID) if err := os.MkdirAll(dir, 0o750); err != nil { diff --git a/internal/multiagent/path_reservation.go b/internal/multiagent/path_reservation.go new file mode 100644 index 00000000..b5de10fb --- /dev/null +++ b/internal/multiagent/path_reservation.go @@ -0,0 +1,191 @@ +// path_reservation.go implements mission-level file-path reservation, +// adopting Luvus' "reserve file paths / coordinate dependent tasks" +// orchestration primitive: agents claim the paths they touch so the +// orchestrator can detect overlapping changes between parallel branches +// before merge and sequence dependent tasks instead of discovering +// conflicts in git. +package mission + +import ( + "fmt" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// Reservation is one agent's claim over a set of paths. +type Reservation struct { + AgentID string `json:"agent_id"` + FeatureID string `json:"feature_id,omitempty"` + Paths []string `json:"paths"` + ClaimedAt time.Time `json:"claimed_at"` +} + +// Conflict reports paths claimed by more than one agent. +type Conflict struct { + Path string `json:"path"` + Agents []string `json:"agents"` +} + +// PathReservationLedger tracks which agent holds which paths. Thread-safe. +type PathReservationLedger struct { + mu sync.Mutex + byPath map[string]*Reservation // canonical path -> holder + byAgent map[string][]string // agent id -> held canonical paths +} + +// NewPathReservationLedger creates an empty ledger. +func NewPathReservationLedger() *PathReservationLedger { + return &PathReservationLedger{ + byPath: map[string]*Reservation{}, + byAgent: map[string][]string{}, + } +} + +func canonPath(p string) string { + return strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(p)), "./") +} + +// Reserve claims paths for agentID. If any path is already held by a +// DIFFERENT agent, nothing is claimed and the conflicting holders are +// returned. Re-reserving paths the same agent already holds is a no-op +// for those paths (idempotent). +func (l *PathReservationLedger) Reserve(agentID, featureID string, paths []string) error { + if agentID == "" { + return fmt.Errorf("multiagent: reserve requires an agent id") + } + l.mu.Lock() + defer l.mu.Unlock() + + // Check conflicts first; claim nothing on any conflict (all-or-nothing). + var conflicts []string + for _, p := range paths { + cp := canonPath(p) + if cp == "" { + continue + } + if holder, ok := l.byPath[cp]; ok && holder.AgentID != agentID { + conflicts = append(conflicts, fmt.Sprintf("%s (held by %s)", cp, holder.AgentID)) + } + } + if len(conflicts) > 0 { + return fmt.Errorf("multiagent: path reservation conflict for %s: %s", agentID, strings.Join(conflicts, ", ")) + } + + now := time.Now() + for _, p := range paths { + cp := canonPath(p) + if cp == "" { + continue + } + if _, ok := l.byPath[cp]; !ok { + l.byPath[cp] = &Reservation{AgentID: agentID, FeatureID: featureID, Paths: []string{cp}, ClaimedAt: now} + l.byAgent[agentID] = append(l.byAgent[agentID], cp) + } + } + return nil +} + +// Release drops every path held by agentID. Returns the number released. +func (l *PathReservationLedger) Release(agentID string) int { + l.mu.Lock() + defer l.mu.Unlock() + held := l.byAgent[agentID] + for _, cp := range held { + if r, ok := l.byPath[cp]; ok && r.AgentID == agentID { + delete(l.byPath, cp) + } + } + delete(l.byAgent, agentID) + return len(held) +} + +// Holder returns the agent currently holding path ("" when free). +func (l *PathReservationLedger) Holder(path string) string { + l.mu.Lock() + defer l.mu.Unlock() + if r, ok := l.byPath[canonPath(path)]; ok { + return r.AgentID + } + return "" +} + +// HeldBy lists all paths currently held by agentID, sorted. +func (l *PathReservationLedger) HeldBy(agentID string) []string { + l.mu.Lock() + defer l.mu.Unlock() + out := append([]string{}, l.byAgent[agentID]...) + sort.Strings(out) + return out +} + +// Overlap is a pair of features whose changed-file sets intersect. +type Overlap struct { + FeatureA string `json:"feature_a"` + FeatureB string `json:"feature_b"` + Paths []string `json:"paths"` +} + +// DetectFileOverlaps compares completed handoffs pairwise and reports every +// pair of features that touched the same files — the merge-conflict forecast. +// Deterministic ordering (by feature id then path). +func DetectFileOverlaps(handoffs map[string][]string) []Overlap { + ids := make([]string, 0, len(handoffs)) + for id := range handoffs { + ids = append(ids, id) + } + sort.Strings(ids) + + pathOwners := map[string][]string{} // path -> sorted feature ids + for _, id := range ids { + seen := map[string]bool{} + for _, p := range handoffs[id] { + cp := canonPath(p) + if cp == "" || seen[cp] { + continue + } + seen[cp] = true + pathOwners[cp] = append(pathOwners[cp], id) + } + } + + pairKey := map[string]*Overlap{} + var keys []string + for _, p := range sortedPaths(pathOwners) { + owners := pathOwners[p] + if len(owners) < 2 { + continue + } + for i := 0; i < len(owners); i++ { + for j := i + 1; j < len(owners); j++ { + k := owners[i] + "\x00" + owners[j] + o, ok := pairKey[k] + if !ok { + o = &Overlap{FeatureA: owners[i], FeatureB: owners[j]} + pairKey[k] = o + keys = append(keys, k) + } + o.Paths = append(o.Paths, p) + } + } + } + sort.Strings(keys) + out := make([]Overlap, 0, len(keys)) + for _, k := range keys { + o := pairKey[k] + sort.Strings(o.Paths) + out = append(out, *o) + } + return out +} + +func sortedPaths(m map[string][]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/internal/multiagent/path_reservation_test.go b/internal/multiagent/path_reservation_test.go new file mode 100644 index 00000000..d11da97f --- /dev/null +++ b/internal/multiagent/path_reservation_test.go @@ -0,0 +1,106 @@ +package mission + +import ( + "strings" + "testing" +) + +func TestReserveAndHolder(t *testing.T) { + l := NewPathReservationLedger() + if err := l.Reserve("agent-1", "feat-1", []string{"internal/a.go", "internal/b.go"}); err != nil { + t.Fatalf("Reserve: %v", err) + } + if h := l.Holder("internal/a.go"); h != "agent-1" { + t.Fatalf("holder = %q", h) + } + // Idempotent re-reserve by same agent. + if err := l.Reserve("agent-1", "feat-1", []string{"internal/a.go"}); err != nil { + t.Fatalf("re-reserve: %v", err) + } + held := l.HeldBy("agent-1") + if len(held) != 2 { + t.Fatalf("held = %v", held) + } +} + +func TestReserveConflictAllOrNothing(t *testing.T) { + l := NewPathReservationLedger() + if err := l.Reserve("a", "f1", []string{"x.go"}); err != nil { + t.Fatal(err) + } + err := l.Reserve("b", "f2", []string{"y.go", "x.go"}) + if err == nil { + t.Fatal("expected conflict error") + } + if !strings.Contains(err.Error(), "x.go (held by a)") { + t.Fatalf("error = %v", err) + } + // All-or-nothing: y.go must NOT have been claimed by b. + if h := l.Holder("y.go"); h != "" { + t.Fatalf("partial claim leaked: y.go held by %q", h) + } + if h := l.Holder("x.go"); h != "a" { + t.Fatalf("x.go holder changed: %q", h) + } +} + +func TestReleaseFreesPaths(t *testing.T) { + l := NewPathReservationLedger() + _ = l.Reserve("a", "f1", []string{"p/q.go"}) + if n := l.Release("a"); n != 1 { + t.Fatalf("released = %d", n) + } + if h := l.Holder("p/q.go"); h != "" { + t.Fatalf("still held by %q", h) + } + // Another agent can now claim it. + if err := l.Reserve("b", "f2", []string{"p/q.go"}); err != nil { + t.Fatalf("claim after release: %v", err) + } +} + +func TestDetectFileOverlaps(t *testing.T) { + handoffs := map[string][]string{ + "feat-b": {"cmd/main.go", "internal/shared.go"}, + "feat-a": {"internal/a.go", "internal/shared.go"}, + "feat-c": {"internal/c.go"}, + } + overlaps := DetectFileOverlaps(handoffs) + if len(overlaps) != 1 { + t.Fatalf("overlaps = %+v", overlaps) + } + o := overlaps[0] + if o.FeatureA != "feat-a" || o.FeatureB != "feat-b" { + t.Fatalf("pair = %s/%s", o.FeatureA, o.FeatureB) + } + if len(o.Paths) != 1 || o.Paths[0] != "internal/shared.go" { + t.Fatalf("paths = %v", o.Paths) + } +} + +func TestDetectFileOverlapsNoneAndMultiPair(t *testing.T) { + if got := DetectFileOverlaps(map[string][]string{"a": {"x"}, "b": {"y"}}); len(got) != 0 { + t.Fatalf("expected no overlaps, got %+v", got) + } + got := DetectFileOverlaps(map[string][]string{ + "a": {"shared", "only-a"}, + "b": {"shared"}, + "c": {"shared"}, + }) + if len(got) != 3 { // a-b, a-c, b-c all share "shared" + t.Fatalf("overlaps = %+v", got) + } + for _, o := range got { + if len(o.Paths) != 1 || o.Paths[0] != "shared" { + t.Fatalf("pair paths wrong: %+v", o) + } + } +} + +func TestCanonicalPathNormalization(t *testing.T) { + l := NewPathReservationLedger() + _ = l.Reserve("a", "f", []string{"./internal/x.go"}) + if h := l.Holder("internal/x.go"); h != "a" { + t.Fatalf("./ prefix not normalized: holder=%q", h) + } +}