Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
62 changes: 62 additions & 0 deletions internal/daemon/routes_agent_status.go
Original file line number Diff line number Diff line change
@@ -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)
}
70 changes: 70 additions & 0 deletions internal/daemon/routes_agent_status_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
35 changes: 35 additions & 0 deletions internal/multiagent/mission.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading