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:
djuka 2026-02-20 12:59:41 +00:00
parent 0e6d0ecd66
commit 10c510d9ef
9 changed files with 473 additions and 3 deletions

View 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
View 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

View File

@ -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")

View File

@ -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

View File

@ -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"))

View File

@ -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)

View File

@ -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; }

View File

@ -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();

View File

@ -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}}