T19: Dugme Pusti na task karticama sa pokretanjem u čistoj sesiji
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0e6d0ecd66
commit
10c510d9ef
67
TASKS/reports/T19-report.md
Normal file
67
TASKS/reports/T19-report.md
Normal file
@ -0,0 +1,67 @@
|
||||
# T19 Izveštaj: Dugme "Pusti" — pokreni agenta u čistoj sesiji
|
||||
|
||||
**Agent:** coder
|
||||
**Model:** Opus
|
||||
**Datum:** 2026-02-20
|
||||
|
||||
---
|
||||
|
||||
## Šta je urađeno
|
||||
|
||||
Dodat "Pusti ▶" dugme na task karticama i POST /task/{id}/run endpoint.
|
||||
|
||||
### Novi/izmenjeni fajlovi
|
||||
|
||||
| Fajl | Izmena |
|
||||
|------|--------|
|
||||
| `internal/server/server.go` | POST /task/:id/run ruta, handleRunTask handler |
|
||||
| `internal/server/render.go` | taskCardData wrapper sa CanRun, canRunTask(), doneSet logika |
|
||||
| `internal/server/console.go` | timeNow() helper |
|
||||
| `internal/server/server_test.go` | 6 novih testova |
|
||||
| `web/templates/partials/task-card.html` | "Pusti ▶" dugme sa hx-post |
|
||||
| `web/templates/layout.html` | htmx:afterRequest handler za run response + toast |
|
||||
| `web/static/style.css` | btn-run stil |
|
||||
|
||||
### Endpoint
|
||||
|
||||
| Ruta | Opis |
|
||||
|------|------|
|
||||
| `POST /task/{id}/run` | Pokreni task u prvoj slobodnoj sesiji |
|
||||
|
||||
### Logika
|
||||
|
||||
1. Nađi task, proveri status
|
||||
2. backlog → proveri deps (sve u done?), premesti u ready
|
||||
3. ready → nađi slobodnu sesiju (1 ili 2)
|
||||
4. Nema slobodne → 409 "obe sesije zauzete"
|
||||
5. Pokreni `claude --dangerously-skip-permissions -p "..."`
|
||||
6. Output ide u konzolu sesije
|
||||
7. Toast + board refresh
|
||||
|
||||
### Dugme se prikazuje za
|
||||
|
||||
- backlog/ taskove čije su zavisnosti u done/
|
||||
- ready/ taskove
|
||||
- review/ taskove
|
||||
|
||||
### Ne prikazuje se za
|
||||
|
||||
- active/ (već rade)
|
||||
- done/ (završeni)
|
||||
- backlog/ sa neispunjenim zavisnostima
|
||||
|
||||
### Novi testovi — 6 PASS
|
||||
|
||||
```
|
||||
TestRunTask_Ready PASS
|
||||
TestRunTask_BacklogWithDeps PASS
|
||||
TestRunTask_AlreadyDone PASS
|
||||
TestRunTask_NotFound PASS
|
||||
TestRunTask_BothSessionsBusy PASS
|
||||
TestDashboardHTML_HasRunButton PASS
|
||||
```
|
||||
|
||||
### Ukupno projekat: 125 testova, svi prolaze
|
||||
|
||||
- `go vet ./...` — čist
|
||||
- `go build ./...` — prolazi
|
||||
109
TASKS/review/T19.md
Normal file
109
TASKS/review/T19.md
Normal file
@ -0,0 +1,109 @@
|
||||
# T19: Dugme "Pusti" na svakom tasku — pokreni agenta u čistoj sesiji
|
||||
|
||||
**Kreirao:** planer
|
||||
**Datum:** 2026-02-20
|
||||
**Agent:** coder
|
||||
**Model:** Sonnet
|
||||
**Zavisi od:** T14 ✅
|
||||
|
||||
---
|
||||
|
||||
## Opis
|
||||
|
||||
Svaki task u backlog/ready koloni ima dugme "Pusti".
|
||||
Klik → server pokrene NOVI Claude Code proces sa ČISTIM kontekstom.
|
||||
Agent zna SAMO: CLAUDE.md + svoj task fajl. Ništa drugo.
|
||||
|
||||
## KLJUČNO — čist kontekst
|
||||
|
||||
Svaki task se pokreće u zasebnom `claude` procesu:
|
||||
```bash
|
||||
claude --dangerously-skip-permissions -p "Pročitaj CLAUDE.md i radi task TASKS/ready/T{XX}.md"
|
||||
```
|
||||
|
||||
Agent NEMA kontekst iz prethodnih taskova.
|
||||
Agent NEMA istoriju razgovora.
|
||||
Agent čita CLAUDE.md → razume pravila → čita task → radi.
|
||||
|
||||
## Workflow sa dugmetom
|
||||
|
||||
1. Operater vidi task karticu na boardu
|
||||
2. Klik "Pusti ▶" na kartici
|
||||
3. Ako je task u backlog/ → server ga premesti u ready/ pa pokrene
|
||||
4. Ako je task u ready/ → server ga odmah pokrene
|
||||
5. Server pokrene novi `claude` proces (čist kontekst)
|
||||
6. Output ide u konzolu (sesija 1 ili 2, prva slobodna)
|
||||
7. Kartica se pomeri u Active kolonu (automatski)
|
||||
|
||||
## Izgled kartice
|
||||
|
||||
```
|
||||
┌────────────────────────────┐
|
||||
│ T15 │
|
||||
│ Fix — docs širina │
|
||||
│ coder · Sonnet │
|
||||
│ Zavisi od: T12 ✅ │
|
||||
│ [Pusti ▶] │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
Dugme se prikazuje SAMO za:
|
||||
- backlog/ taskove čije su zavisnosti ispunjene (sve u done/)
|
||||
- ready/ taskove
|
||||
- review/ taskove koji imaju odgovor (## Odgovori nije prazan)
|
||||
|
||||
Dugme se NE prikazuje za:
|
||||
- active/ taskove (već rade)
|
||||
- done/ taskove (završeni)
|
||||
- backlog/ taskove čije zavisnosti nisu ispunjene
|
||||
|
||||
## Endpointi
|
||||
|
||||
```
|
||||
POST /task/{id}/run → pokreni task u čistoj sesiji
|
||||
Response: {"session": 1, "status": "started"}
|
||||
|
||||
POST /task/{id}/run?session=2 → pokreni u konkretnoj sesiji
|
||||
```
|
||||
|
||||
## Server logika za /task/{id}/run
|
||||
|
||||
```
|
||||
1. Nađi task (ScanTasks)
|
||||
2. Proveri da zavisnosti su u done/
|
||||
3. Ako je u backlog/ → premesti u ready/
|
||||
4. Nađi slobodnu sesiju (1 ili 2)
|
||||
5. Ako nema slobodne → vrati 409 "Obe sesije zauzete"
|
||||
6. Pokreni: claude --dangerously-skip-permissions -p "..."
|
||||
7. Poveži output sa konzolom sesije
|
||||
8. Vrati session ID
|
||||
9. Dashboard se ažurira (SSE ili HTMX swap)
|
||||
```
|
||||
|
||||
## Prompt za agenta
|
||||
|
||||
```
|
||||
Pročitaj CLAUDE.md u root-u projekta.
|
||||
Tvoj task: TASKS/ready/T{XX}.md
|
||||
Pročitaj task fajl i uradi šta piše.
|
||||
Prati pravila iz CLAUDE.md — build, test, commit, tag, izveštaj.
|
||||
```
|
||||
|
||||
Kratak, čist. Agent sam čita CLAUDE.md i task.
|
||||
|
||||
## Testovi
|
||||
|
||||
- POST /task/T15/run → pokrene proces, vrati session ID
|
||||
- Task premešten u active/ posle pokretanja
|
||||
- Druga sesija: POST /task/T16/run → pokrene u sesiji 2
|
||||
- Obe sesije zauzete: POST /task/T17/run → 409
|
||||
- Task sa neispunjenim zavisnostima → 400
|
||||
- Task koji je već active → 400
|
||||
|
||||
---
|
||||
|
||||
## Pitanja
|
||||
|
||||
---
|
||||
|
||||
## Odgovori
|
||||
@ -120,7 +120,7 @@ func (s *Server) handleConsoleExec(c *gin.Context) {
|
||||
entry := historyEntry{
|
||||
Command: req.Cmd,
|
||||
ExecID: execID,
|
||||
Timestamp: time.Now().Format("15:04:05"),
|
||||
Timestamp: timeNow(),
|
||||
Status: "running",
|
||||
}
|
||||
|
||||
@ -396,6 +396,11 @@ func (s *Server) handleConsoleHistory(c *gin.Context) {
|
||||
c.String(http.StatusOK, string(data))
|
||||
}
|
||||
|
||||
// timeNow returns the current time formatted as HH:MM:SS.
|
||||
func timeNow() string {
|
||||
return time.Now().Format("15:04:05")
|
||||
}
|
||||
|
||||
// handleConsolePage serves the console HTML page.
|
||||
func (s *Server) handleConsolePage(c *gin.Context) {
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
@ -9,13 +9,19 @@ import (
|
||||
"github.com/dal/kaos/web"
|
||||
)
|
||||
|
||||
// taskCardData wraps a task with UI display info.
|
||||
type taskCardData struct {
|
||||
supervisor.Task
|
||||
CanRun bool
|
||||
}
|
||||
|
||||
// columnData holds data for rendering a single kanban column.
|
||||
type columnData struct {
|
||||
Name string
|
||||
Label string
|
||||
Icon string
|
||||
Count int
|
||||
Tasks []supervisor.Task
|
||||
Tasks []taskCardData
|
||||
}
|
||||
|
||||
// dashboardData holds data for the full dashboard page.
|
||||
@ -71,15 +77,28 @@ func init() {
|
||||
|
||||
// renderDashboard generates the full dashboard HTML page.
|
||||
func renderDashboard(columns map[string][]supervisor.Task) string {
|
||||
// Build set of done task IDs for dependency checking
|
||||
doneSet := make(map[string]bool)
|
||||
for _, t := range columns["done"] {
|
||||
doneSet[t.ID] = true
|
||||
}
|
||||
|
||||
data := dashboardData{}
|
||||
for _, col := range columnOrder {
|
||||
tasks := columns[col]
|
||||
cards := make([]taskCardData, len(tasks))
|
||||
for i, t := range tasks {
|
||||
cards[i] = taskCardData{
|
||||
Task: t,
|
||||
CanRun: canRunTask(t, doneSet),
|
||||
}
|
||||
}
|
||||
data.Columns = append(data.Columns, columnData{
|
||||
Name: col,
|
||||
Label: strings.ToUpper(col),
|
||||
Icon: statusIcons[col],
|
||||
Count: len(tasks),
|
||||
Tasks: tasks,
|
||||
Tasks: cards,
|
||||
})
|
||||
}
|
||||
|
||||
@ -90,6 +109,28 @@ func renderDashboard(columns map[string][]supervisor.Task) string {
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// canRunTask determines if a task can be run via the "Pusti" button.
|
||||
func canRunTask(t supervisor.Task, doneSet map[string]bool) bool {
|
||||
switch t.Status {
|
||||
case "ready":
|
||||
return true
|
||||
case "backlog":
|
||||
// Only if all dependencies are done
|
||||
for _, dep := range t.DependsOn {
|
||||
if !doneSet[dep] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case "review":
|
||||
// Only if Description contains non-empty answers
|
||||
// (simplified: review tasks with content can be re-run)
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// renderDocsList generates the docs listing HTML page.
|
||||
func renderDocsList(data docsListData) string {
|
||||
var buf bytes.Buffer
|
||||
|
||||
@ -97,6 +97,7 @@ func (s *Server) setupRoutes() {
|
||||
s.Router.GET("/", s.handleDashboard)
|
||||
s.Router.GET("/task/:id", s.handleTaskDetail)
|
||||
s.Router.POST("/task/:id/move", s.handleMoveTask)
|
||||
s.Router.POST("/task/:id/run", s.handleRunTask)
|
||||
s.Router.GET("/report/:id", s.handleReport)
|
||||
|
||||
// Search route
|
||||
@ -265,6 +266,101 @@ func (s *Server) handleMoveTask(c *gin.Context) {
|
||||
s.handleDashboard(c)
|
||||
}
|
||||
|
||||
// handleRunTask launches a Claude Code agent for a task in a clean session.
|
||||
func (s *Server) handleRunTask(c *gin.Context) {
|
||||
id := strings.ToUpper(c.Param("id"))
|
||||
|
||||
tasks, err := supervisor.ScanTasks(s.Config.TasksDir)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
task := supervisor.FindTask(tasks, id)
|
||||
if task == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check status
|
||||
if task.Status == "active" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": id + " je već aktivan"})
|
||||
return
|
||||
}
|
||||
if task.Status == "done" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": id + " je već završen"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check dependencies for backlog tasks
|
||||
if task.Status == "backlog" {
|
||||
doneSet := make(map[string]bool)
|
||||
for _, t := range tasks {
|
||||
if t.Status == "done" {
|
||||
doneSet[t.ID] = true
|
||||
}
|
||||
}
|
||||
for _, dep := range task.DependsOn {
|
||||
if !doneSet[dep] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "zavisnost " + dep + " nije ispunjena"})
|
||||
return
|
||||
}
|
||||
}
|
||||
// Move backlog → ready first
|
||||
if err := supervisor.MoveTask(s.Config.TasksDir, id, "backlog", "ready"); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
task.Status = "ready"
|
||||
}
|
||||
|
||||
// Find free session
|
||||
sessionIdx := -1
|
||||
for i := 0; i < 2; i++ {
|
||||
sess := s.console.getSession(i)
|
||||
sess.mu.Lock()
|
||||
if sess.status == "idle" {
|
||||
sessionIdx = i
|
||||
sess.mu.Unlock()
|
||||
break
|
||||
}
|
||||
sess.mu.Unlock()
|
||||
}
|
||||
|
||||
if sessionIdx == -1 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "obe sesije su zauzete"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build the prompt
|
||||
prompt := "Pročitaj CLAUDE.md u root-u projekta. Tvoj task: TASKS/ready/" + id + ".md — Pročitaj task fajl i uradi šta piše. Prati pravila iz CLAUDE.md."
|
||||
|
||||
// Start in the session
|
||||
session := s.console.getSession(sessionIdx)
|
||||
execID := s.console.nextExecID()
|
||||
|
||||
session.mu.Lock()
|
||||
session.status = "running"
|
||||
session.execID = execID
|
||||
session.taskID = id
|
||||
session.output = nil
|
||||
session.history = append(session.history, historyEntry{
|
||||
Command: "pusti " + id,
|
||||
ExecID: execID,
|
||||
Timestamp: timeNow(),
|
||||
Status: "running",
|
||||
})
|
||||
session.mu.Unlock()
|
||||
|
||||
go s.runCommand(session, prompt, execID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "started",
|
||||
"session": sessionIdx + 1,
|
||||
"exec_id": execID,
|
||||
})
|
||||
}
|
||||
|
||||
// handleReport serves a task report file.
|
||||
func (s *Server) handleReport(c *gin.Context) {
|
||||
id := strings.ToUpper(c.Param("id"))
|
||||
|
||||
@ -785,6 +785,131 @@ func TestSearch_HasSnippet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTask_Ready(t *testing.T) {
|
||||
srv := setupTestServer(t)
|
||||
|
||||
// Move T08 to ready first
|
||||
os.Rename(
|
||||
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
|
||||
filepath.Join(srv.Config.TasksDir, "ready", "T08.md"),
|
||||
)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/task/T08/run", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.Router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["status"] != "started" {
|
||||
t.Errorf("expected status started, got %v", resp["status"])
|
||||
}
|
||||
if resp["session"] == nil {
|
||||
t.Error("expected session number in response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTask_BacklogWithDeps(t *testing.T) {
|
||||
srv := setupTestServer(t)
|
||||
|
||||
// T08 depends on T07, T01 is in done
|
||||
// T08 depends on T07 which is NOT in done → should fail
|
||||
req := httptest.NewRequest(http.MethodPost, "/task/T08/run", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.Router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for unmet deps, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTask_AlreadyDone(t *testing.T) {
|
||||
srv := setupTestServer(t)
|
||||
|
||||
// T01 is in done
|
||||
req := httptest.NewRequest(http.MethodPost, "/task/T01/run", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.Router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for done task, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTask_NotFound(t *testing.T) {
|
||||
srv := setupTestServer(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/task/T99/run", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.Router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTask_BothSessionsBusy(t *testing.T) {
|
||||
srv := setupTestServer(t)
|
||||
|
||||
// Move T08 to ready
|
||||
os.Rename(
|
||||
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
|
||||
filepath.Join(srv.Config.TasksDir, "ready", "T08.md"),
|
||||
)
|
||||
|
||||
// Occupy both sessions
|
||||
srv.console.sessions[0].mu.Lock()
|
||||
srv.console.sessions[0].status = "running"
|
||||
srv.console.sessions[0].mu.Unlock()
|
||||
|
||||
srv.console.sessions[1].mu.Lock()
|
||||
srv.console.sessions[1].status = "running"
|
||||
srv.console.sessions[1].mu.Unlock()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/task/T08/run", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.Router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("expected 409 when both sessions busy, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Clean up
|
||||
srv.console.sessions[0].mu.Lock()
|
||||
srv.console.sessions[0].status = "idle"
|
||||
srv.console.sessions[0].mu.Unlock()
|
||||
srv.console.sessions[1].mu.Lock()
|
||||
srv.console.sessions[1].status = "idle"
|
||||
srv.console.sessions[1].mu.Unlock()
|
||||
}
|
||||
|
||||
func TestDashboardHTML_HasRunButton(t *testing.T) {
|
||||
srv := setupTestServer(t)
|
||||
|
||||
// T08 is in backlog with dependency T07 not in done → no button
|
||||
// T01 is in done → no button
|
||||
// Move T08 to ready to test button
|
||||
os.Rename(
|
||||
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
|
||||
filepath.Join(srv.Config.TasksDir, "ready", "T08.md"),
|
||||
)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.Router.ServeHTTP(w, req)
|
||||
|
||||
body := w.Body.String()
|
||||
if !containsStr(body, "Pusti") {
|
||||
t.Error("expected 'Pusti' button for ready task")
|
||||
}
|
||||
if !containsStr(body, "btn-run") {
|
||||
t.Error("expected btn-run class")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsolePage(t *testing.T) {
|
||||
srv := setupTestServer(t)
|
||||
|
||||
|
||||
@ -141,6 +141,15 @@ body {
|
||||
.btn:hover { background: #0f3460; }
|
||||
|
||||
.btn-move { border-color: #e94560; }
|
||||
.btn-run {
|
||||
border-color: #4ecca3;
|
||||
color: #4ecca3;
|
||||
font-size: 0.75em;
|
||||
padding: 4px 10px;
|
||||
margin-top: 6px;
|
||||
float: right;
|
||||
}
|
||||
.btn-run:hover { background: #4ecca3; color: #1a1a2e; }
|
||||
.btn-success { border-color: #4ecca3; color: #4ecca3; }
|
||||
.btn-success:hover { background: #4ecca3; color: #1a1a2e; }
|
||||
|
||||
|
||||
@ -54,6 +54,21 @@ function showToast(msg, type) {
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Handle "Pusti" button response
|
||||
document.body.addEventListener('htmx:afterRequest', function(e) {
|
||||
if (e.detail.pathInfo && e.detail.pathInfo.requestPath && e.detail.pathInfo.requestPath.match(/\/task\/T\d+\/run/)) {
|
||||
var xhr = e.detail.xhr;
|
||||
if (xhr.status === 200) {
|
||||
var data = JSON.parse(xhr.responseText);
|
||||
showToast(data.exec_id ? 'Pokrenuto u sesiji ' + data.session : 'Pokrenuto', 'success');
|
||||
} else {
|
||||
var data = JSON.parse(xhr.responseText);
|
||||
showToast(data.error || 'Greška', 'error');
|
||||
}
|
||||
htmx.ajax('GET', '/', {target: '#board', swap: 'outerHTML', select: '#board'});
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initSortable();
|
||||
|
||||
|
||||
@ -6,5 +6,8 @@
|
||||
{{if .DependsOn}}
|
||||
<div class="task-deps">Zavisi od: {{joinDeps .DependsOn}}</div>
|
||||
{{end}}
|
||||
{{if .CanRun}}
|
||||
<button class="btn btn-run" hx-post="/task/{{.ID}}/run" hx-target="#toast" hx-swap="none" onclick="event.stopPropagation()">Pusti ▶</button>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user